Improve ssh tran file transfer reliability
This commit is contained in:
@@ -21,6 +21,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
|
||||
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." },
|
||||
{ command: "ssh <route> apply-patch < patch.diff", description: "Default remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
|
||||
{ command: "ssh <route> upload <local-file> <remote-file> | ssh <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." },
|
||||
{ command: "ssh <providerId> apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
|
||||
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; the default sh inherits provider proxy env, so --shell bash is only for bash-specific syntax." },
|
||||
@@ -150,6 +151,8 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh <route>",
|
||||
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
|
||||
"bun scripts/cli.ts ssh <providerId>:/absolute/workspace apply-patch < patch.diff",
|
||||
"bun scripts/cli.ts ssh <route> upload <local-file> <remote-file>",
|
||||
"bun scripts/cli.ts ssh <route> download <remote-file> <local-file>",
|
||||
"bun scripts/cli.ts ssh <providerId> apply-patch-v1 [--allow-loose] < patch.diff",
|
||||
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
|
||||
"bun scripts/cli.ts ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'",
|
||||
@@ -170,6 +173,8 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch-v1 <<'PATCH'",
|
||||
"tar -C /path/to/files -cf - . | bun scripts/cli.ts ssh D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk",
|
||||
"bun scripts/cli.ts ssh D601:win upload ./tool.mjs F:\\Work\\hwlab\\.tmp\\tool.mjs",
|
||||
"bun scripts/cli.ts ssh D601:win download F:\\Work\\hwlab\\.tmp\\tool.mjs ./tool.mjs",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80",
|
||||
@@ -181,6 +186,7 @@ export function sshHelp(): unknown {
|
||||
"When a one-line shell command is easier to type through the script path, `script -- '<command && command>'` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `tran D601:/work script -- sed -n '1,20p' file`.",
|
||||
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
|
||||
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
|
||||
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and fall back from a single stdin payload to bounded client-side chunks before treating provider-gateway limits as a server-side problem.",
|
||||
"`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
|
||||
"script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
|
||||
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win cmd <command-line>` to run Windows host cmd.exe with UTF-8 defaults, and `<provider>:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
|
||||
|
||||
+21
-1
@@ -7,6 +7,7 @@ import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
||||
import {
|
||||
buildWindowsPowerShellInvocation,
|
||||
formatSshFailureHint,
|
||||
formatSshRuntimeTimeoutHint,
|
||||
formatSshRuntimeTimingHint,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
wrapSshRemoteCommand,
|
||||
type SshCaptureResult,
|
||||
} from "./ssh";
|
||||
import { isSshFileTransferOperation, runSshFileTransferOperation, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
|
||||
import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
|
||||
import { runDecisionCenterCommandAsync } from "./decision-center";
|
||||
@@ -1085,7 +1087,16 @@ async function runRemoteSshWebSocketCapture(
|
||||
command: string[],
|
||||
input?: string,
|
||||
): Promise<SshCaptureResult> {
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command);
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined });
|
||||
return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input);
|
||||
}
|
||||
|
||||
async function runRemoteSshWebSocketCaptureRemoteCommand(
|
||||
session: FrontendSession,
|
||||
invocation: ReturnType<typeof parseSshInvocation>,
|
||||
remoteCommand: string,
|
||||
input?: string,
|
||||
): Promise<SshCaptureResult> {
|
||||
const captureInvocation = {
|
||||
...invocation,
|
||||
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
|
||||
@@ -1253,6 +1264,15 @@ export function remoteSshFrontendPlanForTest(target: string, args: string[]): Re
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise<number> {
|
||||
if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const invocation = parseSshInvocation(target, args);
|
||||
if (isSshFileTransferOperation(args)) {
|
||||
const executor: SshRemoteCommandExecutor = {
|
||||
runRemoteCommand: (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input),
|
||||
};
|
||||
return await runSshFileTransferOperation(invocation, args, executor, {
|
||||
buildRouteCommand: remoteCommandForRoute,
|
||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
||||
});
|
||||
}
|
||||
if ((args[0] ?? "") === "apply-patch") {
|
||||
const executor: ApplyPatchV2Executor = {
|
||||
run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input),
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ParsedSshInvocation, ParsedSshRoute, SshCaptureResult } from "./ssh";
|
||||
|
||||
export interface SshRemoteCommandExecutor {
|
||||
runRemoteCommand(remoteCommand: string, input?: string): Promise<SshCaptureResult>;
|
||||
}
|
||||
|
||||
export interface SshFileTransferCommandBuilders {
|
||||
buildRouteCommand(route: ParsedSshRoute, command: string[], options?: { stdin?: boolean }): string;
|
||||
buildWindowsPowerShellCommand(script: string): string;
|
||||
}
|
||||
|
||||
type SshFileTransferOperation =
|
||||
| "stat"
|
||||
| "read-b64-block"
|
||||
| "write-b64-argv"
|
||||
| "write-b64-stdin"
|
||||
| "write-b64-begin"
|
||||
| "write-b64-append-stdin"
|
||||
| "write-b64-commit";
|
||||
|
||||
interface SshFileTransferCliOptions {
|
||||
action: "upload" | "download";
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
readBlockBytes: number;
|
||||
}
|
||||
|
||||
interface SshFileTransferStat {
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface SshFileTransferWriteResult {
|
||||
strategy: "argv" | "stdin" | "chunked-stdin";
|
||||
chunks: number;
|
||||
}
|
||||
|
||||
class SshFileTransferError extends Error {
|
||||
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
|
||||
super(message);
|
||||
this.name = "SshFileTransferError";
|
||||
}
|
||||
}
|
||||
|
||||
const fileTransferReadBlockBytes = 45_000;
|
||||
const fileTransferWriteB64ArgvLimit = 48_000;
|
||||
const fileTransferWriteB64ChunkChars = 12_000;
|
||||
|
||||
export function isSshFileTransferOperation(args: string[]): boolean {
|
||||
const subcommand = args[0] ?? "";
|
||||
return subcommand === "upload" || subcommand === "download";
|
||||
}
|
||||
|
||||
export async function runSshFileTransferOperation(
|
||||
invocation: ParsedSshInvocation,
|
||||
args: string[],
|
||||
executor: SshRemoteCommandExecutor,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
): Promise<number> {
|
||||
const options = parseSshFileTransferCliOptions(args);
|
||||
const localPath = path.resolve(options.localPath);
|
||||
if (options.action === "upload") {
|
||||
const content = await readFile(localPath);
|
||||
const expected = { bytes: content.length, sha256: sha256HexBuffer(content) };
|
||||
const write = await writeRemoteFileVerified(invocation, executor, builders, options.remotePath, content);
|
||||
const remote = await statRemoteFile(invocation, executor, builders, options.remotePath);
|
||||
assertTransferStat("upload final remote verification", options.remotePath, expected, remote);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
command: "ssh upload",
|
||||
route: invocation.route.raw,
|
||||
providerId: invocation.providerId,
|
||||
localPath,
|
||||
remotePath: options.remotePath,
|
||||
bytes: expected.bytes,
|
||||
sha256: expected.sha256,
|
||||
verified: true,
|
||||
transfer: write,
|
||||
}, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const read = await readRemoteFileVerified(invocation, executor, builders, options.remotePath, options.readBlockBytes);
|
||||
await mkdir(path.dirname(localPath), { recursive: true });
|
||||
await writeFile(localPath, read.content);
|
||||
const local = await readFile(localPath);
|
||||
const localStat = { bytes: local.length, sha256: sha256HexBuffer(local) };
|
||||
assertTransferStat("download final local verification", localPath, read.remote, localStat);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
command: "ssh download",
|
||||
route: invocation.route.raw,
|
||||
providerId: invocation.providerId,
|
||||
remotePath: options.remotePath,
|
||||
localPath,
|
||||
bytes: read.remote.bytes,
|
||||
sha256: read.remote.sha256,
|
||||
verified: true,
|
||||
transfer: {
|
||||
strategy: "chunked-read",
|
||||
chunks: read.chunks,
|
||||
chunkBytes: options.readBlockBytes,
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptions {
|
||||
const action = args[0];
|
||||
if (action !== "upload" && action !== "download") throw new Error("ssh file transfer requires upload or download");
|
||||
const positionals: string[] = [];
|
||||
let readBlockBytes = fileTransferReadBlockBytes;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (arg === "--") {
|
||||
positionals.push(...args.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (arg === "--chunk-bytes" || arg === "--block-bytes") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined) throw new Error(`ssh ${action} ${arg} requires a value`);
|
||||
readBlockBytes = boundedTransferChunkBytes(value, `ssh ${action} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h" || arg === "help") {
|
||||
throw new Error(`ssh ${action} usage: ssh <route> ${action} ${action === "upload" ? "<local-file> <remote-file>" : "<remote-file> <local-file>"} [--chunk-bytes N]`);
|
||||
}
|
||||
if (arg.startsWith("-")) throw new Error(`unsupported ssh ${action} option: ${arg}`);
|
||||
positionals.push(arg);
|
||||
}
|
||||
if (positionals.length !== 2) {
|
||||
throw new Error(`ssh ${action} requires exactly two paths: ${action === "upload" ? "<local-file> <remote-file>" : "<remote-file> <local-file>"}`);
|
||||
}
|
||||
const [first, second] = positionals;
|
||||
if (!first || !second) throw new Error(`ssh ${action} paths must be non-empty`);
|
||||
return action === "upload"
|
||||
? { action, localPath: first, remotePath: second, readBlockBytes }
|
||||
: { action, remotePath: first, localPath: second, readBlockBytes };
|
||||
}
|
||||
|
||||
function boundedTransferChunkBytes(raw: string, option: string): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${option} must be a positive integer`);
|
||||
return Math.min(96_000, Math.max(1024, value));
|
||||
}
|
||||
|
||||
async function writeRemoteFileVerified(
|
||||
invocation: ParsedSshInvocation,
|
||||
executor: SshRemoteCommandExecutor,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
remotePath: string,
|
||||
content: Buffer,
|
||||
): Promise<SshFileTransferWriteResult> {
|
||||
const encoded = content.toString("base64");
|
||||
const expectedBytes = String(content.length);
|
||||
const expectedSha256 = sha256HexBuffer(content);
|
||||
if (invocation.route.plane !== "win" && encoded.length <= fileTransferWriteB64ArgvLimit) {
|
||||
await checkedFileTransfer(invocation, executor, builders, "write-b64-argv", [remotePath, expectedBytes, expectedSha256, ...chunkString(encoded, fileTransferWriteB64ChunkChars)]);
|
||||
return { strategy: "argv", chunks: encoded.length === 0 ? 0 : Math.ceil(encoded.length / fileTransferWriteB64ChunkChars) };
|
||||
}
|
||||
try {
|
||||
await checkedFileTransfer(invocation, executor, builders, "write-b64-stdin", [remotePath, expectedBytes, expectedSha256], encoded);
|
||||
return { strategy: "stdin", chunks: 1 };
|
||||
} catch {
|
||||
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
||||
const chunks = chunkString(encoded, fileTransferWriteB64ChunkChars);
|
||||
await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]);
|
||||
for (const chunk of chunks) {
|
||||
await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], chunk);
|
||||
}
|
||||
await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]);
|
||||
return { strategy: "chunked-stdin", chunks: encoded.length === 0 ? 0 : chunks.length };
|
||||
}
|
||||
}
|
||||
|
||||
async function readRemoteFileVerified(
|
||||
invocation: ParsedSshInvocation,
|
||||
executor: SshRemoteCommandExecutor,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
remotePath: string,
|
||||
readBlockBytes: number,
|
||||
): Promise<{ remote: SshFileTransferStat; content: Buffer; chunks: number }> {
|
||||
const remote = await statRemoteFile(invocation, executor, builders, remotePath);
|
||||
const chunks: Buffer[] = [];
|
||||
let actualBytes = 0;
|
||||
let chunkCount = 0;
|
||||
for (let blockIndex = 0; actualBytes < remote.bytes; blockIndex += 1) {
|
||||
const read = await checkedFileTransfer(invocation, executor, builders, "read-b64-block", [remotePath, String(blockIndex), String(readBlockBytes)]);
|
||||
const encoded = read.stdout.replace(/\s+/gu, "");
|
||||
const chunk = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
||||
if (chunk.length === 0) {
|
||||
throw new SshFileTransferError("remote download returned an empty block before EOF", {
|
||||
route: invocation.route.raw,
|
||||
remotePath,
|
||||
blockIndex,
|
||||
expectedBytes: remote.bytes,
|
||||
actualBytes,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
actualBytes += chunk.length;
|
||||
chunkCount += 1;
|
||||
}
|
||||
const content = Buffer.concat(chunks);
|
||||
const actual = { bytes: content.length, sha256: sha256HexBuffer(content) };
|
||||
assertTransferStat("download remote read verification", remotePath, remote, actual);
|
||||
return { remote, content, chunks: chunkCount };
|
||||
}
|
||||
|
||||
async function statRemoteFile(
|
||||
invocation: ParsedSshInvocation,
|
||||
executor: SshRemoteCommandExecutor,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
remotePath: string,
|
||||
): Promise<SshFileTransferStat> {
|
||||
const stat = await checkedFileTransfer(invocation, executor, builders, "stat", [remotePath]);
|
||||
return parseFileTransferStat(stat.stdout, remotePath);
|
||||
}
|
||||
|
||||
async function checkedFileTransfer(
|
||||
invocation: ParsedSshInvocation,
|
||||
executor: SshRemoteCommandExecutor,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
operation: SshFileTransferOperation,
|
||||
args: string[],
|
||||
input?: string,
|
||||
): Promise<SshCaptureResult> {
|
||||
const remoteCommand = buildFileTransferRemoteCommand(invocation.route, builders, operation, args, input !== undefined);
|
||||
const result = await executor.runRemoteCommand(remoteCommand, input);
|
||||
if (result.exitCode === 0) return result;
|
||||
throw new SshFileTransferError("remote ssh file transfer operation failed", {
|
||||
route: invocation.route.raw,
|
||||
operation,
|
||||
args: args.slice(0, 4),
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout.slice(-2000),
|
||||
stderr: result.stderr.slice(-4000),
|
||||
});
|
||||
}
|
||||
|
||||
function buildFileTransferRemoteCommand(
|
||||
route: ParsedSshRoute,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
operation: SshFileTransferOperation,
|
||||
args: string[],
|
||||
hasInput: boolean,
|
||||
): string {
|
||||
if (route.plane === "win") return buildWindowsFileTransferCommand(route, builders, operation, args);
|
||||
return builders.buildRouteCommand(route, ["sh", "-c", posixFileTransferScript(), "unidesk-file-transfer", operation, ...args], { stdin: hasInput });
|
||||
}
|
||||
|
||||
function parseFileTransferStat(stdout: string, remotePath: string): SshFileTransferStat {
|
||||
const [bytesText, sha256] = stdout.trim().split(/\s+/u);
|
||||
const bytes = Number(bytesText);
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
|
||||
throw new SshFileTransferError("remote file transfer stat returned invalid metadata", {
|
||||
remotePath,
|
||||
stdout: stdout.slice(0, 500),
|
||||
});
|
||||
}
|
||||
return { bytes, sha256: sha256! };
|
||||
}
|
||||
|
||||
function assertTransferStat(label: string, pathName: string, expected: SshFileTransferStat, actual: SshFileTransferStat): void {
|
||||
if (expected.bytes === actual.bytes && expected.sha256 === actual.sha256) return;
|
||||
throw new SshFileTransferError(`${label} failed`, {
|
||||
path: pathName,
|
||||
expected,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
|
||||
function sha256HexBuffer(value: Buffer): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function chunkString(value: string, chunkSize: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < value.length; index += chunkSize) {
|
||||
chunks.push(value.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks.length > 0 ? chunks : [""];
|
||||
}
|
||||
|
||||
function posixFileTransferScript(): string {
|
||||
return [
|
||||
"set -eu",
|
||||
"sha256_file() {",
|
||||
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
||||
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
||||
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
||||
" printf 'missing sha256 tool\\n' >&2; return 127",
|
||||
"}",
|
||||
"ensure_parent() { case \"$1\" in */*) parent=${1%/*}; mkdir -p -- \"$parent\";; esac; }",
|
||||
"verify_tmp() {",
|
||||
" target=$1; tmp=$2; expected_bytes=$3; expected_sha256=$4",
|
||||
" actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
|
||||
" if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then rm -f -- \"$tmp\"; printf 'transfer byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2; exit 23; fi",
|
||||
" actual_sha256=$(sha256_file \"$tmp\")",
|
||||
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then rm -f -- \"$tmp\"; printf 'transfer sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2; exit 24; fi",
|
||||
"}",
|
||||
"set_tmp_paths() {",
|
||||
" target=$1; token=$2",
|
||||
" case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid transfer temp token\\n' >&2; exit 2;; esac",
|
||||
" base=${target##*/}; dir=.",
|
||||
" case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-transfer-${token}.tmp\"",
|
||||
" tmp_b64=\"$tmp.b64\"",
|
||||
"}",
|
||||
"op=$1; shift",
|
||||
"case \"$op\" in",
|
||||
" stat)",
|
||||
" target=$1; bytes=$(wc -c < \"$target\" | tr -d '[:space:]'); digest=$(sha256_file \"$target\"); printf '%s %s\\n' \"$bytes\" \"$digest\"",
|
||||
" ;;",
|
||||
" read-b64-block)",
|
||||
" target=$1; block_index=$2; block_size=$3",
|
||||
" case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac",
|
||||
" dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null | base64 | tr -d '\\n'",
|
||||
" ;;",
|
||||
" write-b64-argv)",
|
||||
" target=$1; expected_bytes=$2; expected_sha256=$3; shift 3",
|
||||
" ensure_parent \"$target\"; base=${target##*/}; dir=.; case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-transfer-$$.tmp\"; tmp_b64=\"$tmp.b64\"; : > \"$tmp_b64\"",
|
||||
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp_b64\"; done",
|
||||
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
||||
" rm -f -- \"$tmp_b64\"; verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" write-b64-stdin)",
|
||||
" target=$1; expected_bytes=$2; expected_sha256=$3",
|
||||
" ensure_parent \"$target\"; base=${target##*/}; dir=.; case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-transfer-$$.tmp\"",
|
||||
" if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
||||
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" write-b64-begin)",
|
||||
" target=$1; token=$2; ensure_parent \"$target\"; set_tmp_paths \"$target\" \"$token\"; : > \"$tmp_b64\"",
|
||||
" ;;",
|
||||
" write-b64-append-stdin)",
|
||||
" target=$1; token=$2; set_tmp_paths \"$target\" \"$token\"; cat >> \"$tmp_b64\"",
|
||||
" ;;",
|
||||
" write-b64-commit)",
|
||||
" target=$1; token=$2; expected_bytes=$3; expected_sha256=$4; set_tmp_paths \"$target\" \"$token\"",
|
||||
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
||||
" rm -f -- \"$tmp_b64\"; verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" *) printf 'unsupported transfer op: %s\\n' \"$op\" >&2; exit 2;;",
|
||||
"esac",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildWindowsFileTransferCommand(
|
||||
route: ParsedSshRoute,
|
||||
builders: SshFileTransferCommandBuilders,
|
||||
operation: SshFileTransferOperation,
|
||||
args: string[],
|
||||
): string {
|
||||
return builders.buildWindowsPowerShellCommand(windowsFileTransferScript(route.workspace, operation, args));
|
||||
}
|
||||
|
||||
function windowsFileTransferScript(basePath: string | null, operation: SshFileTransferOperation, args: string[]): string {
|
||||
const target = args[0] ?? "";
|
||||
const tokenOrBlock = args[1] ?? "";
|
||||
const third = args[2] ?? "";
|
||||
const fourth = args[3] ?? "";
|
||||
const argvChunks = args.slice(3).map(powerShellSingleQuote).join(", ");
|
||||
return [
|
||||
"$ErrorActionPreference = 'Stop';",
|
||||
"$ProgressPreference = 'SilentlyContinue';",
|
||||
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
`$basePath = ${powerShellSingleQuote(basePath ?? "")};`,
|
||||
`$operation = ${powerShellSingleQuote(operation)};`,
|
||||
`$targetArg = ${powerShellSingleQuote(target)};`,
|
||||
`$arg1 = ${powerShellSingleQuote(tokenOrBlock)};`,
|
||||
`$arg2 = ${powerShellSingleQuote(third)};`,
|
||||
`$arg3 = ${powerShellSingleQuote(fourth)};`,
|
||||
`$argvChunks = @(${argvChunks});`,
|
||||
"function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }",
|
||||
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Fail 'empty transfer path' 2 }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
|
||||
"function Ensure-Parent([string]$Target) { $parent = [System.IO.Path]::GetDirectoryName($Target); if (-not [string]::IsNullOrWhiteSpace($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null } }",
|
||||
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
|
||||
"function Set-TmpPaths([string]$Target, [string]$Token) { if ($Token -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'invalid transfer temp token' 2 }; $dir = [System.IO.Path]::GetDirectoryName($Target); if ([string]::IsNullOrWhiteSpace($dir)) { $dir = (Get-Location).ProviderPath }; $base = [System.IO.Path]::GetFileName($Target); $script:tmp = [System.IO.Path]::Combine($dir, '.' + $base + '.unidesk-transfer-' + $Token + '.tmp'); $script:tmpB64 = $script:tmp + '.b64' }",
|
||||
"function Verify-Temp([string]$Target, [string]$Tmp, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { $actualBytes = ([System.IO.FileInfo]$Tmp).Length; if ($actualBytes -ne $ExpectedBytes) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('transfer byte count mismatch for ' + $Target + ': expected=' + $ExpectedBytes + ' actual=' + $actualBytes) 23 }; $actualSha256 = Get-Sha256 $Tmp; if ($actualSha256 -ne $ExpectedSha256) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('transfer sha256 mismatch for ' + $Target + ': expected=' + $ExpectedSha256 + ' actual=' + $actualSha256) 24 } }",
|
||||
"function Decode-ToTarget([string]$Target, [string]$Encoded, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { Ensure-Parent $Target; Set-TmpPaths $Target ([guid]::NewGuid().ToString('N')); try { $bytes = [Convert]::FromBase64String(($Encoded -replace '\\s','')) } catch { Fail ('transfer base64 decode failed for ' + $Target + ': ' + $_.Exception.Message) 22 }; [System.IO.File]::WriteAllBytes($script:tmp, $bytes); Verify-Temp $Target $script:tmp $ExpectedBytes $ExpectedSha256; Move-Item -LiteralPath $script:tmp -Destination $Target -Force; $actualSha256 = Get-Sha256 $Target; if ($actualSha256 -ne $ExpectedSha256) { Fail ('transfer final sha256 mismatch for ' + $Target) 25 } }",
|
||||
"$target = Resolve-UnideskPath $targetArg;",
|
||||
"switch ($operation) {",
|
||||
" 'stat' { if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { Fail ('file not found: ' + $target) 1 }; $bytes = ([System.IO.FileInfo]$target).Length; $digest = Get-Sha256 $target; [Console]::Out.WriteLine(([string]$bytes) + ' ' + $digest); break }",
|
||||
" 'read-b64-block' { $blockIndex = [Int64]$arg1; $blockSize = [Int32]$arg2; if ($blockIndex -lt 0 -or $blockSize -le 0) { Fail 'invalid read block args' 2 }; $fs = [System.IO.File]::Open($target, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite); try { [void]$fs.Seek($blockIndex * $blockSize, [System.IO.SeekOrigin]::Begin); $buffer = New-Object byte[] $blockSize; $read = $fs.Read($buffer, 0, $blockSize); if ($read -gt 0) { [Console]::Out.Write([Convert]::ToBase64String($buffer, 0, $read)) } } finally { $fs.Dispose() }; break }",
|
||||
" 'write-b64-argv' { Decode-ToTarget $target ([string]::Concat($argvChunks)) ([Int64]$arg1) $arg2; break }",
|
||||
" 'write-b64-stdin' { Decode-ToTarget $target ([Console]::In.ReadToEnd()) ([Int64]$arg1) $arg2; break }",
|
||||
" 'write-b64-begin' { Ensure-Parent $target; Set-TmpPaths $target $arg1; [System.IO.File]::WriteAllText($script:tmpB64, '', [System.Text.Encoding]::ASCII); break }",
|
||||
" 'write-b64-append-stdin' { Set-TmpPaths $target $arg1; $chunk = ([Console]::In.ReadToEnd()) -replace '\\s',''; [System.IO.File]::AppendAllText($script:tmpB64, $chunk, [System.Text.Encoding]::ASCII); break }",
|
||||
" 'write-b64-commit' { Set-TmpPaths $target $arg1; $encoded = [System.IO.File]::ReadAllText($script:tmpB64, [System.Text.Encoding]::ASCII); Remove-Item -LiteralPath $script:tmpB64 -Force -ErrorAction SilentlyContinue; Decode-ToTarget $target $encoded ([Int64]$arg2) $arg3; break }",
|
||||
" default { Fail ('unsupported transfer op: ' + $operation) 2 }",
|
||||
"}",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function powerShellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
+32
-6
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
import { isSshFileTransferOperation, runSshFileTransferOperation, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
|
||||
|
||||
export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
@@ -824,6 +825,9 @@ export function isSshSkillDiscoveryArgs(args: string[]): boolean {
|
||||
|
||||
export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
const subcommand = args[0] ?? "";
|
||||
if (isSshFileTransferOperation(args)) {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (isSshSkillDiscoveryArgs(args)) {
|
||||
const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)];
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["skill-discover"] };
|
||||
@@ -981,6 +985,9 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
||||
if (operation.length === 0) {
|
||||
throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver or ssh ${route.providerId}:win skills`);
|
||||
}
|
||||
if (operation === "upload" || operation === "download") {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "skills" || operation === "skill-discover" || operation === "discover-skills") {
|
||||
return {
|
||||
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsSkillsDiscoveryScript(args.slice(1))),
|
||||
@@ -1103,7 +1110,7 @@ function buildWindowsCmdLauncherScript(cmdLine: string): string {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildWindowsPowerShellInvocation(script: string): string {
|
||||
export function buildWindowsPowerShellInvocation(script: string): string {
|
||||
return shellArgv([
|
||||
windowsPowerShellExePath,
|
||||
"-NoProfile",
|
||||
@@ -1292,6 +1299,9 @@ function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
||||
|
||||
function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
||||
const operation = args[0] ?? "guard";
|
||||
if (operation === "upload" || operation === "download") {
|
||||
throw new Error(`ssh ${route.providerId}:k3s ${operation} requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> ${operation} ...`);
|
||||
}
|
||||
if (operation === "apply-patch" || operation === "apply-patch-v1") {
|
||||
throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> apply-patch`);
|
||||
}
|
||||
@@ -1323,6 +1333,9 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS
|
||||
}
|
||||
const operation = args[0] ?? "";
|
||||
const operationArgs = args.slice(1);
|
||||
if (operation === "upload" || operation === "download") {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "apply-patch") {
|
||||
if (isApplyPatchV2HelpArgs(operationArgs)) {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
@@ -1361,8 +1374,8 @@ function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSs
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
|
||||
}
|
||||
|
||||
function buildK3sTargetCommand(route: ParsedSshRoute, command: string[]): string {
|
||||
return buildK3sExecCommand([...k3sRouteTargetArgs(route), "--", ...command]);
|
||||
function buildK3sTargetCommand(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string {
|
||||
return buildK3sExecCommand([...k3sRouteTargetArgs(route), ...(options.stdin === true ? ["--stdin"] : []), "--", ...command]);
|
||||
}
|
||||
|
||||
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
@@ -2072,15 +2085,19 @@ function terminalSize(): { cols: number; rows: number } {
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteCommandForRoute(route: ParsedSshRoute, command: string[]): string {
|
||||
if (route.plane === "k3s") return buildK3sTargetCommand(route, command);
|
||||
export function remoteCommandForRoute(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string {
|
||||
if (route.plane === "k3s") return buildK3sTargetCommand(route, command, options);
|
||||
if (route.plane === "win") throw new Error(`ssh apply-patch does not support win routes yet: ${route.raw}`);
|
||||
return shellArgv(command);
|
||||
}
|
||||
|
||||
async function runSshCaptureCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, command: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined });
|
||||
return await runSshCaptureRemoteCommand(config, invocation, remoteCommand, input);
|
||||
}
|
||||
|
||||
async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string): Promise<SshCaptureResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command);
|
||||
const size = terminalSize();
|
||||
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
||||
const payload = {
|
||||
@@ -2184,6 +2201,15 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const invocation = parseSshInvocation(providerId, args);
|
||||
const parsed = invocation.parsed;
|
||||
const operationName = args[0] ?? "";
|
||||
if (isSshFileTransferOperation(args)) {
|
||||
const executor: SshRemoteCommandExecutor = {
|
||||
runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input),
|
||||
};
|
||||
return await runSshFileTransferOperation(invocation, args, executor, {
|
||||
buildRouteCommand: remoteCommandForRoute,
|
||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
||||
});
|
||||
}
|
||||
if (operationName === "apply-patch") {
|
||||
const executor: ApplyPatchV2Executor = { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
|
||||
return await runApplyPatchV2({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { sshHelp } from "./src/help";
|
||||
import { runApplyPatchV2 } from "./src/apply-patch-v2";
|
||||
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
|
||||
import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote";
|
||||
import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./src/ssh-file-transfer";
|
||||
import {
|
||||
formatSshFailureHint,
|
||||
formatSshRuntimeTimeoutHint,
|
||||
@@ -42,6 +43,27 @@ function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex");
|
||||
}
|
||||
|
||||
function sha256BufferHex(value: Buffer): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
async function captureStdout(fn: () => Promise<number>): Promise<{ exitCode: number; stdout: string }> {
|
||||
const originalWrite = process.stdout.write;
|
||||
let stdout = "";
|
||||
process.stdout.write = ((chunk: unknown, ...args: unknown[]) => {
|
||||
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
const callback = args.find((arg): arg is () => void => typeof arg === "function");
|
||||
if (callback) callback();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const exitCode = await fn();
|
||||
return { exitCode, stdout };
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWinEncodedCommand(remoteCommand: string | null | undefined): string {
|
||||
const text = String(remoteCommand ?? "");
|
||||
const match = /'-EncodedCommand' '([^']+)'/u.exec(text);
|
||||
@@ -243,6 +265,78 @@ async function applyPatchV2Fixture(patch: string, files: Record<string, string>)
|
||||
return { stdout: result.stdout, files: result.files, commands: result.commands };
|
||||
}
|
||||
|
||||
function fileTransferFixture(initial: Record<string, Buffer> = {}): {
|
||||
state: Map<string, Buffer>;
|
||||
commands: Array<{ operation: string; stdin: boolean }>;
|
||||
executor: SshRemoteCommandExecutor;
|
||||
builders: SshFileTransferCommandBuilders;
|
||||
} {
|
||||
const state = new Map(Object.entries(initial));
|
||||
const pending = new Map<string, string>();
|
||||
const commands: Array<{ operation: string; stdin: boolean }> = [];
|
||||
const builders: SshFileTransferCommandBuilders = {
|
||||
buildRouteCommand(route, command, options) {
|
||||
return JSON.stringify({ route: route.raw, command, stdin: options?.stdin === true });
|
||||
},
|
||||
buildWindowsPowerShellCommand(script) {
|
||||
return JSON.stringify({ route: "win", command: ["powershell", script], stdin: false });
|
||||
},
|
||||
};
|
||||
const executor: SshRemoteCommandExecutor = {
|
||||
async runRemoteCommand(remoteCommand, input) {
|
||||
const payload = JSON.parse(remoteCommand) as { command: string[]; stdin?: boolean };
|
||||
const command = payload.command;
|
||||
const operation = command[4] ?? "";
|
||||
const target = command[5] ?? "";
|
||||
commands.push({ operation, stdin: payload.stdin === true });
|
||||
if (operation === "stat") {
|
||||
const content = state.get(target);
|
||||
if (content === undefined) return { exitCode: 1, stdout: "", stderr: "missing" };
|
||||
return { exitCode: 0, stdout: `${content.length} ${sha256BufferHex(content)}\n`, stderr: "" };
|
||||
}
|
||||
if (operation === "read-b64-block") {
|
||||
const content = state.get(target);
|
||||
if (content === undefined) return { exitCode: 1, stdout: "", stderr: "missing" };
|
||||
const blockIndex = Number(command[6] ?? "-1");
|
||||
const blockSize = Number(command[7] ?? "-1");
|
||||
const start = blockIndex * blockSize;
|
||||
return { exitCode: 0, stdout: content.subarray(start, start + blockSize).toString("base64"), stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-argv" || operation === "write-b64-stdin") {
|
||||
const expectedBytes = Number(command[6] ?? "-1");
|
||||
const expectedSha256 = command[7] ?? "";
|
||||
const encoded = operation === "write-b64-argv" ? command.slice(8).join("") : String(input ?? "");
|
||||
const content = Buffer.from(encoded, "base64");
|
||||
if (content.length !== expectedBytes || sha256BufferHex(content) !== expectedSha256) return { exitCode: 23, stdout: "", stderr: "integrity mismatch" };
|
||||
state.set(target, content);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-begin") {
|
||||
pending.set(`${target}\0${command[6] ?? ""}`, "");
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-append-stdin") {
|
||||
const key = `${target}\0${command[6] ?? ""}`;
|
||||
if (!pending.has(key)) return { exitCode: 2, stdout: "", stderr: "missing pending write" };
|
||||
pending.set(key, `${pending.get(key) ?? ""}${String(input ?? "")}`);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-commit") {
|
||||
const key = `${target}\0${command[6] ?? ""}`;
|
||||
const expectedBytes = Number(command[7] ?? "-1");
|
||||
const expectedSha256 = command[8] ?? "";
|
||||
const content = Buffer.from(pending.get(key) ?? "", "base64");
|
||||
if (content.length !== expectedBytes || sha256BufferHex(content) !== expectedSha256) return { exitCode: 23, stdout: "", stderr: "integrity mismatch" };
|
||||
state.set(target, content);
|
||||
pending.delete(key);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { exitCode: 2, stdout: "", stderr: `unsupported op ${operation}` };
|
||||
},
|
||||
};
|
||||
return { state, commands, executor, builders };
|
||||
}
|
||||
|
||||
export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
const argv = parseSshArgs(["argv", "true"]);
|
||||
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
|
||||
@@ -285,6 +379,31 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
const winSkillsScript = decodeWinEncodedCommand(winSkills.parsed.remoteCommand);
|
||||
assertCondition(winSkillsScript.includes(".agents\\skills") && winSkillsScript.includes(".codex\\skills") && winSkillsScript.includes("$limit = 20") && winSkillsScript.includes("ConvertTo-Json"), "win skills must discover Windows user skill roots as JSON", { winSkills, winSkillsScript });
|
||||
|
||||
const hostUploadParse = parseSshInvocation("D601", ["upload", "/tmp/local.bin", "/tmp/remote.bin"]);
|
||||
assertCondition(hostUploadParse.parsed.remoteCommand === null && hostUploadParse.parsed.invocationKind === "helper", "host upload must be a structured local operation, not an ssh-like command string", hostUploadParse);
|
||||
const winDownloadParse = parseSshInvocation("D601:win", ["download", String.raw`F:\Work\hwlab\.tmp\tool.mjs`, "/tmp/tool.mjs"]);
|
||||
assertCondition(winDownloadParse.route.plane === "win" && winDownloadParse.parsed.remoteCommand === null, "win download must be handled by the file-transfer operation module", winDownloadParse);
|
||||
const podUploadParse = parseSshInvocation("D601:k3s:unidesk:code-queue/root/unidesk", ["upload", "/tmp/local.bin", "/root/unidesk/.tmp/remote.bin"]);
|
||||
assertCondition(podUploadParse.route.plane === "k3s" && podUploadParse.parsed.remoteCommand === null, "pod upload must keep k3s route as location-only and defer transfer execution to the operation module", podUploadParse);
|
||||
|
||||
const transferRoot = mkdtempSync(path.join(os.tmpdir(), "unidesk-transfer-contract-"));
|
||||
try {
|
||||
const localSource = path.join(transferRoot, "local-source.bin");
|
||||
const localDownload = path.join(transferRoot, "downloaded", "local-copy.bin");
|
||||
const payload = Buffer.from("hello 中文\n\0binary tail", "utf8");
|
||||
writeFileSync(localSource, payload);
|
||||
const transfer = fileTransferFixture();
|
||||
const uploadResult = await captureStdout(() => runSshFileTransferOperation(hostUploadParse, ["upload", localSource, "/tmp/remote.bin"], transfer.executor, transfer.builders));
|
||||
assertCondition(uploadResult.exitCode === 0 && JSON.parse(uploadResult.stdout).verified === true, "upload should report verified JSON success", uploadResult);
|
||||
assertCondition(transfer.state.get("/tmp/remote.bin")?.equals(payload), "upload must preserve binary and UTF-8 bytes in the mock remote file", transfer.commands);
|
||||
const downloadResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/remote.bin", localDownload]), ["download", "/tmp/remote.bin", localDownload], transfer.executor, transfer.builders));
|
||||
assertCondition(downloadResult.exitCode === 0 && JSON.parse(downloadResult.stdout).sha256 === sha256BufferHex(payload), "download should report the verified sha256", downloadResult);
|
||||
assertCondition(readFileSync(localDownload).equals(payload), "download must preserve binary and UTF-8 bytes locally", { commands: transfer.commands });
|
||||
assertCondition(transfer.commands.some((item) => item.operation === "stat") && transfer.commands.some((item) => item.operation === "read-b64-block"), "file transfer should use stat plus chunked verified reads", transfer.commands);
|
||||
} finally {
|
||||
rmSync(transferRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assertThrows(
|
||||
() => parseSshInvocation("D601:win32", ["cmd", "ver"]),
|
||||
/use D601:win/u,
|
||||
@@ -800,6 +919,7 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd"), "ssh help must document k3s pod workspace route", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:k3s script <<'SCRIPT'"), "ssh help must document k3s control-plane script operation", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH'"), "ssh help must document k3s pod apply-patch operation", helpText);
|
||||
assertCondition(helpText.includes("ssh <route> upload <local-file> <remote-file>") && helpText.includes("ssh <route> download <remote-file> <local-file>"), "ssh help must document verified file transfer operations", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk"), "ssh help must document one-step stdin file streaming into pod exec", helpText);
|
||||
assertCondition(helpText.includes("apply-patch-v1 [--allow-loose]") && helpText.includes("low-context update hunks"), "ssh help must document apply-patch-v1 loose-context guard", helpText);
|
||||
assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'"), "ssh help must document k3s script operation", helpText);
|
||||
@@ -848,6 +968,11 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
assertCondition(!remoteSource.includes("remote frontend transport does not stream stdin"), "remote frontend ssh must not reject stdin-backed helpers", remoteSource);
|
||||
assertCondition(!remoteSource.includes("source: \"cli-remote-ssh\""), "remote frontend ssh must not use host.ssh dispatch task polling", remoteSource);
|
||||
|
||||
const sshSource = readFileSync(new URL("./src/ssh.ts", import.meta.url), "utf8");
|
||||
const sshFileTransferSource = readFileSync(new URL("./src/ssh-file-transfer.ts", import.meta.url), "utf8");
|
||||
assertCondition(sshFileTransferSource.includes("runSshFileTransferOperation") && sshFileTransferSource.includes("write-b64-commit"), "file transfer operation implementation must live in the dedicated ssh-file-transfer module", {});
|
||||
assertCondition(!sshSource.includes("type SshFileTransferOperation") && !sshSource.includes("posixFileTransferScript"), "ssh.ts must not accumulate the full upload/download implementation", {});
|
||||
|
||||
const frontendSource = readFileSync(new URL("../src/components/frontend/src/index.ts", import.meta.url), "utf8");
|
||||
assertCondition(frontendSource.includes('url.pathname === "/ws/ssh"') && frontendSource.includes("proxySshWebSocket"), "frontend must expose an authenticated /ws/ssh proxy", frontendSource);
|
||||
assertCondition(frontendSource.includes("coreSshWebSocketUrl") && frontendSource.includes('url.searchParams.set("token"'), "frontend /ws/ssh proxy must connect to backend-core ssh bridge with the provider token", frontendSource);
|
||||
@@ -870,6 +995,7 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
"script -- single-string runs as a remote shell one-liner while multi-token form keeps dash-prefixed argv",
|
||||
"pod apply-patch operation uses the v2 local engine and apply-patch-v1 injects the legacy helper",
|
||||
"pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping",
|
||||
"upload/download file transfer operations use a dedicated module with byte-count and sha256 verification",
|
||||
"apply-patch-v1 uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit",
|
||||
"legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance",
|
||||
"post-provider k3s shorthand is rejected so location and operation stay separated",
|
||||
|
||||
Reference in New Issue
Block a user