339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { describe, expect, test } from "bun:test";
|
|
import type { UniDeskConfig } from "./config";
|
|
import {
|
|
createPosixApplyPatchFileSystem,
|
|
createSshStderrForwarder,
|
|
createSshStdoutForwarder,
|
|
formatSshStdoutTruncationHint,
|
|
formatSshTruncationCompletionSummary,
|
|
parseSshInvocation,
|
|
sshTruncationCompletionSummary,
|
|
sshCaptureBackendPlan,
|
|
sshStderrStreamMaxBytes,
|
|
sshStdoutStreamMaxBytes,
|
|
sshStdoutTruncationHint,
|
|
windowsFsReadOnlyScript,
|
|
windowsPowerShellScriptPrelude,
|
|
} from "./ssh";
|
|
import { readCliOutputPolicy } from "./output";
|
|
|
|
function sha256Hex(text: string): string {
|
|
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
}
|
|
|
|
function minimalConfig(publicHost = "203.0.113.10"): UniDeskConfig {
|
|
return {
|
|
network: { publicHost },
|
|
} as UniDeskConfig;
|
|
}
|
|
|
|
describe("ssh windows PowerShell safety prelude", () => {
|
|
test("shadows ConvertTo-Json and strips filesystem ETS metadata", () => {
|
|
const prelude = windowsPowerShellScriptPrelude("C:\\test");
|
|
|
|
expect(prelude).toContain("function ConvertTo-UniDeskPlainJsonValue");
|
|
expect(prelude).toContain("function ConvertTo-Json");
|
|
expect(prelude).toContain("'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount'");
|
|
expect(prelude).toContain("Microsoft.PowerShell.Utility\\ConvertTo-Json");
|
|
expect(prelude).toContain("Set-Location -LiteralPath 'C:\\test'");
|
|
});
|
|
});
|
|
|
|
describe("ssh windows fs read-only operations", () => {
|
|
test("routes common read-only commands through the Windows fs backend", () => {
|
|
const invocation = parseSshInvocation("D601:win/c/test", ["cat", "hello.md"]);
|
|
const rgInvocation = parseSshInvocation("D601:win/c/test", ["rg", "-i", "needle", "."]);
|
|
|
|
expect(invocation.route.plane).toBe("win");
|
|
expect(invocation.route.workspace).toBe("C:\\test");
|
|
expect(invocation.parsed.invocationKind).toBe("helper");
|
|
expect(invocation.parsed.requiresStdin).toBe(false);
|
|
expect(invocation.parsed.remoteCommand).toContain("powershell.exe");
|
|
expect(invocation.parsed.remoteCommand).toContain("-EncodedCommand");
|
|
expect(rgInvocation.parsed.invocationKind).toBe("helper");
|
|
expect(rgInvocation.parsed.requiresStdin).toBe(false);
|
|
});
|
|
|
|
test("routes git status, diff, and commit through Windows cmd convenience", () => {
|
|
const statusInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "status", "--short", "--branch"]);
|
|
const diffInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "diff", "--check"]);
|
|
const commitInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "commit", "-m", "fix: update docs"]);
|
|
|
|
expect(statusInvocation.route.plane).toBe("win");
|
|
expect(statusInvocation.route.workspace).toBe("F:\\Work\\ConStart");
|
|
expect(statusInvocation.parsed.invocationKind).toBe("argv");
|
|
expect(statusInvocation.parsed.requiresStdin).toBe(false);
|
|
expect(statusInvocation.parsed.remoteCommand).toContain("powershell.exe");
|
|
expect(diffInvocation.parsed.invocationKind).toBe("argv");
|
|
expect(diffInvocation.parsed.requiresStdin).toBe(false);
|
|
expect(commitInvocation.parsed.invocationKind).toBe("argv");
|
|
expect(commitInvocation.parsed.requiresStdin).toBe(false);
|
|
});
|
|
|
|
test("builds bounded UTF-8 scripts for cat, ls, and rg", () => {
|
|
const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]);
|
|
const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]);
|
|
const rgScript = windowsFsReadOnlyScript("F:\\Work\\demo", "rg", ["-i", "--max-count=5", "needle", "."]);
|
|
|
|
expect(catScript).toContain("$operation = 'cat';");
|
|
expect(catScript).toContain("F:\\Work\\demo");
|
|
expect(catScript).toContain("binary file refused by windows fs read operation");
|
|
expect(catScript).toContain("file is not valid UTF-8");
|
|
expect(catScript).toContain("file too large for windows fs read operation");
|
|
expect(lsScript).toContain("$operation = 'ls';");
|
|
expect(lsScript).toContain("UNIDESK_WINDOWS_FS_TRUNCATED");
|
|
expect(lsScript).toContain("TYPE BYTES UPDATED NAME");
|
|
expect(rgScript).toContain("$operation = 'rg';");
|
|
expect(rgScript).toContain("unsupported rg option on Windows route");
|
|
expect(rgScript).toContain("UNIDESK_WINDOWS_FS_SKIPPED");
|
|
});
|
|
|
|
test("rejects unsupported Windows read operations instead of treating them as POSIX", () => {
|
|
expect(() => parseSshInvocation("D601:win/c/test", ["sed", "-n", "1p", "hello.md"])).toThrow("unsupported ssh win operation: sed");
|
|
});
|
|
|
|
test("reports route-aware hints for repeated win operation and interactive git commit", () => {
|
|
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["win", "ps"])).toThrow("route D601:win/F/Work/ConStart already selects the Windows plane");
|
|
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "commit"])).toThrow("ssh win git commit would open an editor");
|
|
});
|
|
});
|
|
|
|
describe("ssh host apply-patch fs backend", () => {
|
|
test("uses POSIX bulk fs operations for host routes", async () => {
|
|
const invocation = parseSshInvocation("D601:/mnt/f/Work/ConStart", ["apply-patch"]);
|
|
const text = "alpha\nold\nomega\n";
|
|
const calls: Array<{ operation: string; args: string[]; input?: string }> = [];
|
|
const fs = createPosixApplyPatchFileSystem(invocation, async (command, input) => {
|
|
const operation = command[4] ?? "";
|
|
const args = command.slice(5);
|
|
calls.push({ operation, args, input });
|
|
if (operation === "read-bulk-b64") {
|
|
return {
|
|
exitCode: 0,
|
|
stderr: "",
|
|
stdout: [
|
|
"UNIDESK_APPLY_PATCH_V2_BULK_READ 1",
|
|
[
|
|
Buffer.from("docs/test.md", "utf8").toString("base64"),
|
|
String(Buffer.byteLength(text, "utf8")),
|
|
sha256Hex(text),
|
|
Buffer.from(text, "utf8").toString("base64"),
|
|
].join(" "),
|
|
"",
|
|
].join("\n"),
|
|
};
|
|
}
|
|
if (operation === "apply-replacements-bulk-stdin") {
|
|
expect(input).toContain(Buffer.from("docs/test.md", "utf8").toString("base64"));
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
}
|
|
throw new Error(`unexpected operation ${operation}`);
|
|
});
|
|
|
|
const files = await fs.readFiles!(["docs/test.md"]);
|
|
await fs.applyReplacementsBulk!(["docs/test.md"], new Map([[
|
|
"docs/test.md",
|
|
{
|
|
path: "docs/test.md",
|
|
originalBytes: Buffer.byteLength(text, "utf8"),
|
|
originalSha256: sha256Hex(text),
|
|
finalBytes: Buffer.byteLength("alpha\nnew\nomega\n", "utf8"),
|
|
finalSha256: sha256Hex("alpha\nnew\nomega\n"),
|
|
replacements: [[1, 1, ["new"]]],
|
|
},
|
|
]]));
|
|
|
|
expect(files.get("docs/test.md")).toBe(text);
|
|
expect(calls.map((call) => call.operation)).toEqual(["read-bulk-b64", "apply-replacements-bulk-stdin"]);
|
|
expect(calls[0]?.args).toEqual(["docs/test.md"]);
|
|
expect(calls[1]?.args).toEqual(["1"]);
|
|
});
|
|
});
|
|
|
|
describe("ssh stdout bounded streaming", () => {
|
|
test("uses YAML output policy defaults and clamps env override", () => {
|
|
const policy = readCliOutputPolicy();
|
|
expect(sshStdoutStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(policy.maxStdoutBytes);
|
|
expect(sshStderrStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(policy.maxStdoutBytes);
|
|
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "8192" } as NodeJS.ProcessEnv)).toBe(8192);
|
|
expect(sshStderrStreamMaxBytes({ UNIDESK_SSH_STDERR_STREAM_MAX_BYTES: "8192" } as NodeJS.ProcessEnv)).toBe(8192);
|
|
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "1" } as NodeJS.ProcessEnv)).toBe(4096);
|
|
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: String(64 * 1024 * 1024) } as NodeJS.ProcessEnv)).toBe(16 * 1024 * 1024);
|
|
});
|
|
|
|
test("formats truncation hint without echoing remote command", () => {
|
|
const invocation = parseSshInvocation("D601:win", ["ps"]);
|
|
expect(invocation.parsed.remoteCommand).not.toBeNull();
|
|
const hint = sshStdoutTruncationHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
thresholdBytes: 4096,
|
|
observedBytesAtTruncation: 5000,
|
|
dumpPath: "/tmp/unidesk-cli-output/sample.stdout.bin",
|
|
});
|
|
const formatted = formatSshStdoutTruncationHint(hint);
|
|
|
|
expect(formatted.startsWith("UNIDESK_SSH_STDOUT_TRUNCATED ")).toBe(true);
|
|
const payload = JSON.parse(formatted.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as Record<string, unknown>;
|
|
expect(payload.code).toBe("ssh-stdout-truncated");
|
|
expect(payload.providerId).toBe("D601");
|
|
expect(payload.route).toBe("D601:win");
|
|
expect(payload.thresholdBytes).toBe(4096);
|
|
expect(payload.disclosurePolicy).toMatchObject({
|
|
name: "unified-cli-dump-preview",
|
|
configPath: "config/unidesk-cli.yaml",
|
|
});
|
|
expect(formatted).not.toContain("Get-Content");
|
|
});
|
|
|
|
test("forwarder bounds stdout and emits one truncation hint", () => {
|
|
const invocation = parseSshInvocation("D601:win", ["ps"]);
|
|
const forwarded: Buffer[] = [];
|
|
const stdout = {
|
|
write(chunk: string | Buffer): boolean {
|
|
forwarded.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return true;
|
|
},
|
|
} as NodeJS.WritableStream;
|
|
const forwarder = createSshStdoutForwarder({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
maxBytes: 5,
|
|
stdout,
|
|
});
|
|
|
|
expect(forwarder.write(Buffer.from("abc"))).toBeNull();
|
|
const hint = forwarder.write(Buffer.from("defgh"));
|
|
expect(hint).not.toBeNull();
|
|
expect(forwarder.write(Buffer.from("ijkl"))).toBeNull();
|
|
expect(Buffer.concat(forwarded).toString("utf8")).toBe("abcde");
|
|
expect(hint).toContain("UNIDESK_SSH_STDOUT_TRUNCATED");
|
|
expect(hint).toContain("\"transport\":\"frontend-websocket\"");
|
|
expect(hint).toContain("\"forwardedBytes\":5");
|
|
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string };
|
|
expect(readFileSync(payload.dumpPath, "utf8")).toBe("abcdefghijkl");
|
|
const summary = sshTruncationCompletionSummary({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
startedAtMs: Date.now() - 1234,
|
|
stdout: forwarder.summary(),
|
|
stderr: null,
|
|
});
|
|
const formattedSummary = formatSshTruncationCompletionSummary(summary);
|
|
expect(formattedSummary).toContain("UNIDESK_SSH_TRUNCATION_SUMMARY");
|
|
expect(formattedSummary).toContain("\"exitCode\":0");
|
|
expect(formattedSummary).toContain("\"commandOmitted\":true");
|
|
expect(formattedSummary).toContain("\"dumpPath\"");
|
|
rmSync(payload.dumpPath, { force: true });
|
|
});
|
|
|
|
test("stderr forwarder uses the same dump guard and marker", () => {
|
|
const invocation = parseSshInvocation("D601:win", ["ps"]);
|
|
const forwarded: Buffer[] = [];
|
|
const stderr = {
|
|
write(chunk: string | Buffer): boolean {
|
|
forwarded.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return true;
|
|
},
|
|
} as NodeJS.WritableStream;
|
|
const forwarder = createSshStderrForwarder({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
maxBytes: 4,
|
|
stderr,
|
|
});
|
|
|
|
const hint = forwarder.write(Buffer.from("abcdef"));
|
|
expect(Buffer.concat(forwarded).toString("utf8")).toBe("abcd");
|
|
expect(hint).toContain("UNIDESK_SSH_STDERR_TRUNCATED");
|
|
expect(hint).toContain("\"stream\":\"stderr\"");
|
|
});
|
|
});
|
|
|
|
describe("ssh backend selection", () => {
|
|
test("uses trans YAML backend-core preference without runtime docker detection", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-trans-config-"));
|
|
const configPath = join(dir, "trans.ymal");
|
|
writeFileSync(configPath, [
|
|
"version: 1",
|
|
"kind: UniDeskTransConfig",
|
|
"ssh:",
|
|
" backend: backend-core",
|
|
"",
|
|
].join("\n"));
|
|
try {
|
|
const plan = sshCaptureBackendPlan(minimalConfig(), { UNIDESK_TRANS_CONFIG_PATH: configPath } as NodeJS.ProcessEnv);
|
|
|
|
expect(plan.backend).toBe("local-backend-core-broker");
|
|
expect(plan.remoteHost).toBeNull();
|
|
expect(plan.reason).toBe(`configured:${configPath}#ssh.backend`);
|
|
expect(plan.localBackendCore.backendCoreContainer).toBe(true);
|
|
expect(plan.localBackendCore.source).toBe(`${configPath}#ssh.backend`);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("uses trans YAML frontend websocket preference when configured", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-trans-config-"));
|
|
const configPath = join(dir, "trans.ymal");
|
|
writeFileSync(configPath, [
|
|
"version: 1",
|
|
"kind: UniDeskTransConfig",
|
|
"ssh:",
|
|
" backend: frontend-websocket",
|
|
"",
|
|
].join("\n"));
|
|
try {
|
|
const plan = sshCaptureBackendPlan(minimalConfig("198.51.100.4"), { UNIDESK_TRANS_CONFIG_PATH: configPath } as NodeJS.ProcessEnv);
|
|
|
|
expect(plan.backend).toBe("remote-frontend-websocket");
|
|
expect(plan.remoteHost).toBe("198.51.100.4");
|
|
expect(plan.reason).toBe(`configured:${configPath}#ssh.backend`);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ssh removed shell aliases", () => {
|
|
test("reports route-aware replacement examples for trans script", () => {
|
|
const previousEntrypoint = process.env.UNIDESK_SSH_ENTRYPOINT;
|
|
process.env.UNIDESK_SSH_ENTRYPOINT = "trans";
|
|
try {
|
|
parseSshInvocation("D601:/tmp", ["script", "--", "pwd"]);
|
|
throw new Error("expected script alias to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
const payload = error as Error & {
|
|
code?: string;
|
|
entrypoint?: string;
|
|
route?: string;
|
|
operation?: string;
|
|
replacementExamples?: {
|
|
posixSh?: string;
|
|
bash?: string;
|
|
};
|
|
};
|
|
expect(payload.code).toBe("ssh-removed-shell-alias");
|
|
expect(payload.entrypoint).toBe("trans");
|
|
expect(payload.route).toBe("D601:/tmp");
|
|
expect(payload.operation).toBe("script");
|
|
expect(payload.replacementExamples?.posixSh).toBe("trans D601:/tmp sh -- 'pwd'");
|
|
expect(payload.replacementExamples?.bash).toBe("trans D601:/tmp bash -- 'pwd'");
|
|
} finally {
|
|
if (previousEntrypoint === undefined) {
|
|
delete process.env.UNIDESK_SSH_ENTRYPOINT;
|
|
} else {
|
|
process.env.UNIDESK_SSH_ENTRYPOINT = previousEntrypoint;
|
|
}
|
|
}
|
|
});
|
|
});
|