fix: add transactional remote patch v2
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
export type PatchHunk =
|
||||
| { kind: "add"; path: string; content: string }
|
||||
| { kind: "delete"; path: string }
|
||||
| { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] };
|
||||
|
||||
export interface UpdateChunk {
|
||||
changeContext: string | null;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
isEndOfFile: boolean;
|
||||
}
|
||||
|
||||
export interface PatchParseResult {
|
||||
patch: string;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
export interface PatchUpdateResult {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2Options {
|
||||
executor: ApplyPatchV2Executor;
|
||||
stdin: Readable;
|
||||
stdout: Writable;
|
||||
argv?: string[];
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2Executor {
|
||||
run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult>;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2RemoteResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class ApplyPatchV2Error extends Error {
|
||||
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
|
||||
super(message);
|
||||
this.name = "ApplyPatchV2Error";
|
||||
}
|
||||
}
|
||||
|
||||
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
|
||||
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
|
||||
type Replacement = [start: number, oldLength: number, newLines: string[]];
|
||||
|
||||
interface ApplyPatchV2Plan {
|
||||
operations: PlannedOperation[];
|
||||
changed: string[];
|
||||
}
|
||||
|
||||
const beginMarker = "*** Begin Patch";
|
||||
const endMarker = "*** End Patch";
|
||||
const addFileMarker = "*** Add File: ";
|
||||
const deleteFileMarker = "*** Delete File: ";
|
||||
const updateFileMarker = "*** Update File: ";
|
||||
const moveToMarker = "*** Move to: ";
|
||||
const emptyChangeContextMarker = "@@";
|
||||
const changeContextMarker = "@@ ";
|
||||
const eofMarker = "*** End of File";
|
||||
|
||||
export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
|
||||
const first = args[0] ?? "";
|
||||
return first === "help" || first === "--help" || first === "-h";
|
||||
}
|
||||
|
||||
export function applyPatchV2HelpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
command: "ssh <route> v2 < patch.diff",
|
||||
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
|
||||
usage: [
|
||||
"tran G14:/root/hwlab/.worktree/task v2 < patch.diff",
|
||||
"bun scripts/cli.ts ssh D601:/tmp v2 < patch.diff"
|
||||
],
|
||||
input: {
|
||||
required: true,
|
||||
firstLine: beginMarker,
|
||||
lastLine: endMarker
|
||||
},
|
||||
note: "v2 reads patch text from stdin. Use `script -- ...` for ordinary remote shell commands."
|
||||
};
|
||||
}
|
||||
|
||||
export function parseApplyPatchV2(patchText: string): PatchParseResult {
|
||||
const patch = stripLenientHeredoc(patchText).trim();
|
||||
const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u);
|
||||
const first = lines[0]?.trim();
|
||||
const last = lines[lines.length - 1]?.trim();
|
||||
if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`);
|
||||
if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`);
|
||||
|
||||
const hunks: PatchHunk[] = [];
|
||||
let index = 1;
|
||||
while (index < lines.length - 1) {
|
||||
const line = lines[index]?.trim() ?? "";
|
||||
if (line.length === 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(addFileMarker)) {
|
||||
const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1);
|
||||
index += 1;
|
||||
const added: string[] = [];
|
||||
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
||||
const addLine = lines[index] ?? "";
|
||||
if (!addLine.startsWith("+")) {
|
||||
throw new ApplyPatchV2Error("invalid add file line; every added line must start with +", { line: index + 1, path: filePath });
|
||||
}
|
||||
added.push(addLine.slice(1));
|
||||
index += 1;
|
||||
}
|
||||
hunks.push({ kind: "add", path: filePath, content: joinLinesWithFinalNewline(added) });
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(deleteFileMarker)) {
|
||||
hunks.push({ kind: "delete", path: validatePatchPath(line.slice(deleteFileMarker.length), index + 1) });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(updateFileMarker)) {
|
||||
const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1);
|
||||
index += 1;
|
||||
let movePath: string | null = null;
|
||||
if ((lines[index] ?? "").startsWith(moveToMarker)) {
|
||||
movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1);
|
||||
index += 1;
|
||||
}
|
||||
const chunks: UpdateChunk[] = [];
|
||||
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
||||
if ((lines[index] ?? "").trim().length === 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const parsed = parseUpdateChunk(lines, index, chunks.length === 0);
|
||||
chunks.push(parsed.chunk);
|
||||
index = parsed.nextIndex;
|
||||
}
|
||||
if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath });
|
||||
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
||||
continue;
|
||||
}
|
||||
throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line });
|
||||
}
|
||||
|
||||
return { patch, hunks };
|
||||
}
|
||||
|
||||
export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult {
|
||||
const originalLines = splitContentLines(originalContent);
|
||||
const replacements = computeReplacements(filePath, originalLines, chunks);
|
||||
const newLines = applyReplacements(originalLines, replacements);
|
||||
const newContent = joinLinesWithFinalNewline(newLines);
|
||||
return { oldContent: originalContent, newContent };
|
||||
}
|
||||
|
||||
export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<number> {
|
||||
if (isApplyPatchV2HelpArgs(options.argv)) {
|
||||
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
const patchText = await readStreamText(options.stdin);
|
||||
if (!patchText.trim()) {
|
||||
options.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "v2_patch_stdin_required",
|
||||
message: "ssh v2 requires patch text on stdin."
|
||||
},
|
||||
help: applyPatchV2HelpPayload()
|
||||
}, null, 2)}\n`);
|
||||
return 2;
|
||||
}
|
||||
const parsed = parseApplyPatchV2(patchText);
|
||||
const plan = await planApplyPatchV2(options.executor, parsed.hunks);
|
||||
for (const operation of plan.operations) {
|
||||
await executePlannedOperation(options.executor, operation);
|
||||
}
|
||||
options.stdout.write("Success. Updated the following files:\n");
|
||||
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
|
||||
const states = new Map<string, PlannedFileState>();
|
||||
const operations: PlannedOperation[] = [];
|
||||
const changed: string[] = [];
|
||||
|
||||
async function readPlannedText(filePath: string): Promise<string> {
|
||||
const state = states.get(filePath);
|
||||
if (state !== undefined) {
|
||||
if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath });
|
||||
return state.content;
|
||||
}
|
||||
const content = await readRemoteText(executor, filePath);
|
||||
states.set(filePath, { exists: true, content });
|
||||
return content;
|
||||
}
|
||||
|
||||
function planWrite(filePath: string, content: string): void {
|
||||
states.set(filePath, { exists: true, content });
|
||||
operations.push({ kind: "write", path: filePath, content });
|
||||
}
|
||||
|
||||
function planDelete(filePath: string): void {
|
||||
states.set(filePath, { exists: false, content: "" });
|
||||
operations.push({ kind: "delete", path: filePath });
|
||||
}
|
||||
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.kind === "add") {
|
||||
planWrite(hunk.path, hunk.content);
|
||||
changed.push(hunk.path);
|
||||
continue;
|
||||
}
|
||||
if (hunk.kind === "delete") {
|
||||
planDelete(hunk.path);
|
||||
changed.push(hunk.path);
|
||||
continue;
|
||||
}
|
||||
const originalContent = await readPlannedText(hunk.path);
|
||||
const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks);
|
||||
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
||||
planWrite(hunk.movePath, update.newContent);
|
||||
planDelete(hunk.path);
|
||||
changed.push(`${hunk.path} -> ${hunk.movePath}`);
|
||||
continue;
|
||||
}
|
||||
planWrite(hunk.path, update.newContent);
|
||||
changed.push(hunk.path);
|
||||
}
|
||||
|
||||
return { operations, changed };
|
||||
}
|
||||
|
||||
async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise<void> {
|
||||
if (operation.kind === "write") {
|
||||
await writeRemoteText(executor, operation.path, operation.content);
|
||||
return;
|
||||
}
|
||||
await checkedRemoteV2(executor, "delete", [operation.path]);
|
||||
}
|
||||
|
||||
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
|
||||
const read = await checkedRemoteV2(executor, "read", [target]);
|
||||
return read.stdout;
|
||||
}
|
||||
|
||||
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
|
||||
const contentBuffer = Buffer.from(content, "utf8");
|
||||
const encoded = contentBuffer.toString("base64");
|
||||
const expectedBytes = String(contentBuffer.length);
|
||||
const expectedSha256 = sha256Hex(contentBuffer);
|
||||
if (encoded.length <= 48_000) {
|
||||
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]);
|
||||
return;
|
||||
}
|
||||
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
|
||||
}
|
||||
|
||||
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> {
|
||||
const result = await executor.run(remoteV2Script(operation, args), input);
|
||||
if (result.exitCode === 0) return result;
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
|
||||
operation,
|
||||
args,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout.slice(-2000),
|
||||
stderr: result.stderr.slice(-4000),
|
||||
});
|
||||
}
|
||||
|
||||
function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] {
|
||||
const script = [
|
||||
"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",
|
||||
"}",
|
||||
"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 'v2 decoded 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 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2",
|
||||
" exit 24",
|
||||
" fi",
|
||||
"}",
|
||||
"op=$1",
|
||||
"shift",
|
||||
"case \"$op\" in",
|
||||
" read)",
|
||||
" cat -- \"$1\"",
|
||||
" ;;",
|
||||
" write-b64-argv)",
|
||||
" target=$1",
|
||||
" expected_bytes=$2",
|
||||
" expected_sha256=$3",
|
||||
" shift 3",
|
||||
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
||||
" base=${target##*/}",
|
||||
" dir=.",
|
||||
" case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
||||
" : > \"$tmp.b64\"",
|
||||
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done",
|
||||
" base64 -d < \"$tmp.b64\" > \"$tmp\"",
|
||||
" 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 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" write-b64-stdin)",
|
||||
" target=$1",
|
||||
" expected_bytes=$2",
|
||||
" expected_sha256=$3",
|
||||
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
||||
" base=${target##*/}",
|
||||
" dir=.",
|
||||
" case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
||||
" base64 -d > \"$tmp\"",
|
||||
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
||||
" mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\")",
|
||||
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" delete)",
|
||||
" rm -f -- \"$1\"",
|
||||
" ;;",
|
||||
" move)",
|
||||
" target=$2",
|
||||
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
||||
" mv -f -- \"$1\" \"$2\"",
|
||||
" ;;",
|
||||
" *)",
|
||||
" printf 'unsupported op: %s\\n' \"$op\" >&2",
|
||||
" exit 2",
|
||||
" ;;",
|
||||
"esac",
|
||||
].join("\n");
|
||||
return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args];
|
||||
}
|
||||
|
||||
function sha256Hex(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 readStreamText(stream: Readable): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
stream.resume();
|
||||
});
|
||||
}
|
||||
|
||||
function stripLenientHeredoc(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
|
||||
const first = lines[0] ?? "";
|
||||
const last = lines[lines.length - 1] ?? "";
|
||||
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) {
|
||||
return lines.slice(1, -1).join("\n");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function validatePatchPath(value: string, line: number): string {
|
||||
const filePath = value.trim();
|
||||
if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line });
|
||||
if (path.isAbsolute(filePath)) throw new ApplyPatchV2Error("patch paths must be relative", { line, path: filePath });
|
||||
if (filePath.split(/[\\/]+/u).includes("..")) throw new ApplyPatchV2Error("patch paths cannot contain ..", { line, path: filePath });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function isFileHeader(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === endMarker;
|
||||
}
|
||||
|
||||
function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean): { chunk: UpdateChunk; nextIndex: number } {
|
||||
let index = startIndex;
|
||||
let changeContext: string | null = null;
|
||||
const first = lines[index] ?? "";
|
||||
if (first === emptyChangeContextMarker) {
|
||||
index += 1;
|
||||
} else if (first.startsWith(changeContextMarker)) {
|
||||
changeContext = first.slice(changeContextMarker.length);
|
||||
index += 1;
|
||||
} else if (!allowMissingContext) {
|
||||
throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { line: startIndex + 1, text: first });
|
||||
}
|
||||
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
let parsed = 0;
|
||||
let isEndOfFile = false;
|
||||
while (index < lines.length - 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (isFileHeader(line)) break;
|
||||
if (line === eofMarker) {
|
||||
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 });
|
||||
isEndOfFile = true;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const marker = line[0] ?? "";
|
||||
if (marker === " ") {
|
||||
oldLines.push(line.slice(1));
|
||||
newLines.push(line.slice(1));
|
||||
} else if (marker === "+") {
|
||||
newLines.push(line.slice(1));
|
||||
} else if (marker === "-") {
|
||||
oldLines.push(line.slice(1));
|
||||
} else if (line.length === 0) {
|
||||
oldLines.push("");
|
||||
newLines.push("");
|
||||
} else if (parsed > 0) {
|
||||
break;
|
||||
} else {
|
||||
throw new ApplyPatchV2Error("unexpected line in update chunk", { line: index + 1, text: line });
|
||||
}
|
||||
parsed += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 });
|
||||
return { chunk: { changeContext, oldLines, newLines, isEndOfFile }, nextIndex: index };
|
||||
}
|
||||
|
||||
function splitContentLines(content: string): string[] {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function joinLinesWithFinalNewline(lines: string[]): string {
|
||||
if (lines.length === 0) return "";
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] {
|
||||
const replacements: Replacement[] = [];
|
||||
let lineIndex = 0;
|
||||
for (const [chunkIndex, chunk] of chunks.entries()) {
|
||||
if (chunk.changeContext !== null) {
|
||||
const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
||||
if (foundContext === null) {
|
||||
throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext });
|
||||
}
|
||||
lineIndex = foundContext + 1;
|
||||
}
|
||||
if (chunk.oldLines.length === 0) {
|
||||
const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length;
|
||||
replacements.push([insertAt, 0, chunk.newLines]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let pattern = chunk.oldLines;
|
||||
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
||||
let newLines = chunk.newLines;
|
||||
if (found === null && pattern[pattern.length - 1] === "") {
|
||||
pattern = pattern.slice(0, -1);
|
||||
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
|
||||
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
||||
}
|
||||
if (found === null) {
|
||||
throw new ApplyPatchV2Error("failed to find expected lines", {
|
||||
path: filePath,
|
||||
chunk: chunkIndex + 1,
|
||||
expected: chunk.oldLines.join("\n"),
|
||||
});
|
||||
}
|
||||
replacements.push([found, pattern.length, newLines]);
|
||||
lineIndex = found + pattern.length;
|
||||
}
|
||||
assertNonOverlappingReplacements(filePath, replacements, originalLines.length);
|
||||
return replacements;
|
||||
}
|
||||
|
||||
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
|
||||
const result = [...lines];
|
||||
for (const [start, oldLen, newSegment] of [...replacements].reverse()) {
|
||||
result.splice(start, oldLen, ...newSegment);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void {
|
||||
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
||||
let previousEnd = 0;
|
||||
for (const [start, oldLen] of sorted) {
|
||||
if (start < 0 || oldLen < 0 || start + oldLen > lineCount) {
|
||||
throw new ApplyPatchV2Error("computed replacement is outside file bounds", {
|
||||
path: filePath,
|
||||
start,
|
||||
oldLen,
|
||||
lineCount,
|
||||
});
|
||||
}
|
||||
if (start < previousEnd) {
|
||||
throw new ApplyPatchV2Error("computed replacements overlap", {
|
||||
path: filePath,
|
||||
start,
|
||||
previousEnd,
|
||||
});
|
||||
}
|
||||
previousEnd = Math.max(previousEnd, start + oldLen);
|
||||
}
|
||||
}
|
||||
|
||||
function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null {
|
||||
if (pattern.length === 0) return start;
|
||||
if (pattern.length > lines.length) return null;
|
||||
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
||||
const attempts: Array<(value: string) => string> = [
|
||||
(value) => value,
|
||||
(value) => value.trimEnd(),
|
||||
(value) => value.trim(),
|
||||
normalizeLine,
|
||||
];
|
||||
for (const normalize of attempts) {
|
||||
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
|
||||
let ok = true;
|
||||
for (let offset = 0; offset < pattern.length; offset += 1) {
|
||||
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeLine(value: string): string {
|
||||
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
|
||||
}
|
||||
+10
-6
@@ -20,14 +20,15 @@ export function rootHelp(): unknown {
|
||||
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
|
||||
{ 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 <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "ssh <route> v2 < patch.diff", description: "Preferred 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 <providerId> apply-patch [tool args...] < patch.diff", description: "Fallback to the injected 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." },
|
||||
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
|
||||
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
|
||||
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
|
||||
{ command: "ssh <providerId>:/absolute/workspace <operation args...>", description: "Route directly into a host workspace while keeping the operation parser independent from the location." },
|
||||
{ command: "ssh <providerId>:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|apply-patch|command> ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." },
|
||||
{ command: "ssh <providerId>:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|v2|apply-patch|command> ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." },
|
||||
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh <providerId> script` when shell features are required." },
|
||||
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
@@ -148,6 +149,7 @@ export function sshHelp(): unknown {
|
||||
usage: [
|
||||
"bun scripts/cli.ts ssh <route>",
|
||||
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
|
||||
"bun scripts/cli.ts ssh <providerId>:/absolute/workspace v2 < patch.diff",
|
||||
"bun scripts/cli.ts ssh <providerId> apply-patch [--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'",
|
||||
@@ -165,6 +167,7 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci",
|
||||
"bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app v2 <<'PATCH'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'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:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
|
||||
@@ -174,10 +177,11 @@ export function sshHelp(): unknown {
|
||||
notes: [
|
||||
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
|
||||
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
|
||||
"For one-line remote shell logic without a heredoc, use `shell '<command && command>'`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI.",
|
||||
"When a short remote command is easier to type through the script path and needs dash-prefixed argv, write `script -- <command> [args...]`; this direct form does not wait for stdin, and the command-local `--` is preserved by local and remote `tran` parsing, so examples such as `tran D601:/work script -- sed -n '1,20p' file` do not require a heredoc just to pass `-n`.",
|
||||
"For one-line remote shell logic without a heredoc, use `script -- '<command && command>'`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI. The explicit `shell '<command>'` operation remains available for the same sh -c path.",
|
||||
"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 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.",
|
||||
"For remote text patches, prefer `v2`; it keeps the legacy apply-patch command unchanged but uses a local line-based patch engine and remote read/write operations, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
|
||||
"Legacy apply-patch is a fallback: 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.",
|
||||
"Use `win`, not `win32`; the win route sets chcp 65001, PYTHONUTF8=1, and PYTHONIOENCODING=utf-8 before running the requested cmd command line.",
|
||||
@@ -186,7 +190,7 @@ export function sshHelp(): unknown {
|
||||
"Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.",
|
||||
"If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.",
|
||||
"Non-interactive ssh/tran operations have a hard top-level runtime timeout capped at 60s. Timeout writes UNIDESK_SSH_RUNTIME_TIMEOUT or UNIDESK_TRAN_TIMEOUT_HINT and disconnects the broker; long CI/CD, trace, logs, build, or hardware work must use submit-and-poll / short query loops instead of keeping tran open.",
|
||||
"Only slow ssh/tran runtime writes UNIDESK_SSH_TIMING JSON to stderr; operations over 10s are marked level=warning even when they succeed, because slow successful calls are a distributed performance monitoring signal. Check provider latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency work. Routine short calls do not emit timing noise.",
|
||||
"Only slow ssh/tran runtime writes UNIDESK_SSH_TIMING JSON to stderr; operations over 10s are marked level=warning even when they succeed, because slow successful calls are a distributed performance monitoring signal. Check provider latency, remote command cost, helper bootstrap, or remote patch optimization before repeating high-frequency work. Routine short calls do not emit timing noise.",
|
||||
"The local tran wrapper must not add provider/plane directory locks; rely on k8s/Tekton/Argo/Lease or server-side TTL queues for coordination.",
|
||||
"Use -- before a remote command that intentionally starts with a dash.",
|
||||
],
|
||||
|
||||
@@ -11,13 +11,16 @@ import {
|
||||
formatSshRuntimeTimeoutHint,
|
||||
formatSshRuntimeTimingHint,
|
||||
parseSshInvocation,
|
||||
remoteCommandForRoute,
|
||||
sshFailureHint,
|
||||
sshRoutePayloadCwd,
|
||||
sshRuntimeTimeoutHint,
|
||||
sshRuntimeTimeoutMs,
|
||||
sshRuntimeTimingHint,
|
||||
wrapSshRemoteCommand,
|
||||
type SshCaptureResult,
|
||||
} from "./ssh";
|
||||
import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
|
||||
import { runDecisionCenterCommandAsync } from "./decision-center";
|
||||
import {
|
||||
@@ -83,6 +86,7 @@ const portOptions = new Set(["--main-server-port", "--server-port"]);
|
||||
const rootOptions = new Set(["--main-server-root", "--server-root"]);
|
||||
const keyOptions = new Set(["--main-server-key", "--server-key"]);
|
||||
const transportOptions = new Set(["--main-server-transport", "--server-transport"]);
|
||||
const remoteSshInputChunkBytes = 32 * 1024;
|
||||
|
||||
function positivePort(raw: string, option: string): number {
|
||||
const value = Number(raw);
|
||||
@@ -943,6 +947,12 @@ async function runRemoteSshWebSocket(
|
||||
const sendInput = (value: Buffer | string): void => {
|
||||
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
|
||||
};
|
||||
const sendInputChunked = (value: string): void => {
|
||||
const buffer = Buffer.from(value, "utf8");
|
||||
for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) {
|
||||
sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes)));
|
||||
}
|
||||
};
|
||||
const sendWhenSessionReady = (value: unknown): void => {
|
||||
const text = JSON.stringify(value);
|
||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -1069,6 +1079,163 @@ async function runRemoteSshWebSocket(
|
||||
});
|
||||
}
|
||||
|
||||
async function runRemoteSshWebSocketCapture(
|
||||
session: FrontendSession,
|
||||
invocation: ReturnType<typeof parseSshInvocation>,
|
||||
command: string[],
|
||||
input?: string,
|
||||
): Promise<SshCaptureResult> {
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command);
|
||||
const captureInvocation = {
|
||||
...invocation,
|
||||
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
|
||||
};
|
||||
const startedAtMs = Date.now();
|
||||
const size = {
|
||||
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
||||
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
||||
};
|
||||
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
||||
const payload = {
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(remoteCommand),
|
||||
cwd: sshRoutePayloadCwd(invocation.route),
|
||||
tty: false,
|
||||
stdinEotOnEnd: false,
|
||||
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
||||
runtimeTimeoutMs,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
};
|
||||
const ws = openFrontendSshWebSocket(session);
|
||||
let exitCode = 255;
|
||||
let settled = false;
|
||||
let canSend = false;
|
||||
let sessionReady = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const pending: string[] = [];
|
||||
const pendingSessionMessages: string[] = [];
|
||||
|
||||
const send = (value: unknown): void => {
|
||||
const text = JSON.stringify(value);
|
||||
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
||||
pending.push(text);
|
||||
return;
|
||||
}
|
||||
ws.send(text);
|
||||
};
|
||||
const sendWhenSessionReady = (value: unknown): void => {
|
||||
const text = JSON.stringify(value);
|
||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
||||
pendingSessionMessages.push(text);
|
||||
return;
|
||||
}
|
||||
ws.send(text);
|
||||
};
|
||||
const sendInput = (value: Buffer | string): void => {
|
||||
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
|
||||
};
|
||||
const flush = (): void => {
|
||||
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!);
|
||||
};
|
||||
const flushSessionMessages = (): void => {
|
||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
||||
while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!);
|
||||
};
|
||||
|
||||
return await new Promise<SshCaptureResult>((resolve) => {
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const finish = (code: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(openTimer);
|
||||
clearTimeout(runtimeTimer);
|
||||
if (killTimer !== null) clearTimeout(killTimer);
|
||||
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
||||
invocation: captureInvocation,
|
||||
transport: "frontend-websocket",
|
||||
exitCode: code,
|
||||
startedAtMs,
|
||||
}));
|
||||
if (timingHint) stderr += timingHint;
|
||||
resolve({ exitCode: code, stdout, stderr });
|
||||
};
|
||||
const openTimer = setTimeout(() => {
|
||||
if (sessionReady || settled) return;
|
||||
stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n";
|
||||
exitCode = 255;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, payload.openTimeoutMs);
|
||||
const runtimeTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
exitCode = 124;
|
||||
stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
||||
invocation: captureInvocation,
|
||||
transport: "frontend-websocket",
|
||||
timeoutMs: runtimeTimeoutMs,
|
||||
}));
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
killTimer = setTimeout(() => finish(124), 2000);
|
||||
finish(124);
|
||||
}, runtimeTimeoutMs);
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
canSend = true;
|
||||
send({ type: "ssh.open", ...payload });
|
||||
flush();
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
const text = webSocketDataText(event.data);
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
stderr += `${text}\n`;
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.dispatched") return;
|
||||
if (message.type === "ssh.opened") {
|
||||
sessionReady = true;
|
||||
clearTimeout(openTimer);
|
||||
if (input !== undefined) sendInputChunked(input);
|
||||
sendWhenSessionReady({ type: "ssh.eof" });
|
||||
flushSessionMessages();
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.data") {
|
||||
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8").toString("utf8");
|
||||
if (message.stream === "stderr") stderr += chunk;
|
||||
else stdout += chunk;
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.error") {
|
||||
stderr += `${String(message.message || "ssh bridge error")}\n`;
|
||||
exitCode = 255;
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.exit") {
|
||||
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
ws.addEventListener("close", () => finish(exitCode));
|
||||
ws.addEventListener("error", () => {
|
||||
stderr += "unidesk remote frontend ssh bridge websocket error\n";
|
||||
finish(255);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function remoteSshFrontendPlanForTest(target: string, args: string[]): Record<string, unknown> {
|
||||
const invocation = parseSshInvocation(target, args);
|
||||
return {
|
||||
@@ -1086,6 +1253,12 @@ 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 ((args[0] ?? "") === "v2") {
|
||||
const executor: ApplyPatchV2Executor = {
|
||||
run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input),
|
||||
};
|
||||
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, argv: args.slice(1) });
|
||||
}
|
||||
return runRemoteSshWebSocket(session, invocation);
|
||||
}
|
||||
|
||||
|
||||
+147
-4
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
|
||||
export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
@@ -30,6 +31,12 @@ export interface ParsedSshInvocation {
|
||||
parsed: ParsedSshArgs;
|
||||
}
|
||||
|
||||
export interface SshCaptureResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface SshFailureHint {
|
||||
code: "ssh-like-command-friction";
|
||||
providerId: string;
|
||||
@@ -86,6 +93,7 @@ const legacyK3sOperationRouteSegments = new Set([
|
||||
"exec",
|
||||
"script",
|
||||
"apply-patch",
|
||||
"v2",
|
||||
"logs",
|
||||
"get",
|
||||
"describe",
|
||||
@@ -821,6 +829,12 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
const toolArgs = ["apply_patch", ...args.slice(1)];
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper", requiredHelpers: ["apply_patch"] };
|
||||
}
|
||||
if (subcommand === "v2") {
|
||||
if (isApplyPatchV2HelpArgs(args.slice(1))) {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
if (subcommand === "py") {
|
||||
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
@@ -1153,11 +1167,11 @@ function k3sOperationInRouteMessage(target: string, operation: string): string {
|
||||
return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh ${providerId}:k3s ${operationExample}" or "ssh ${providerId}:k3s:<namespace>:<workload> ${operationExample}" instead of "${target}"`;
|
||||
}
|
||||
|
||||
function shellArgv(args: string[]): string {
|
||||
export function shellArgv(args: string[]): string {
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@@ -1272,6 +1286,9 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P
|
||||
if (operation === "apply-patch" || operation === "patch") {
|
||||
throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> apply-patch`);
|
||||
}
|
||||
if (operation === "v2") {
|
||||
throw new Error(`ssh ${route.providerId}:k3s v2 requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> v2`);
|
||||
}
|
||||
if (operation === "script" || operation === "sh") {
|
||||
return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
@@ -1298,6 +1315,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS
|
||||
const operation = args[0] ?? "";
|
||||
const operationArgs = args.slice(1);
|
||||
if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
|
||||
if (operation === "v2") return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
||||
if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" };
|
||||
if (operation === "shell") {
|
||||
const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`);
|
||||
@@ -1326,6 +1344,10 @@ 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 k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
|
||||
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
|
||||
@@ -1701,6 +1723,7 @@ function buildShellCommand(args: string[]): ParsedSshArgs {
|
||||
}
|
||||
if (directArgvMode) {
|
||||
if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command");
|
||||
if (scriptArgs.length === 1) return { remoteCommand: shellArgv([shell, "-c", scriptArgs[0] ?? ""]), requiresStdin: false, invocationKind: "helper" };
|
||||
return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" };
|
||||
}
|
||||
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" };
|
||||
@@ -2032,9 +2055,127 @@ function terminalSize(): { cols: number; rows: number } {
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteCommandForRoute(route: ParsedSshRoute, command: string[]): string {
|
||||
if (route.plane === "k3s") return buildK3sTargetCommand(route, command);
|
||||
if (route.plane === "win") throw new Error(`ssh v2 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 startedAtMs = Date.now();
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command);
|
||||
const size = terminalSize();
|
||||
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
||||
const payload = {
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(remoteCommand),
|
||||
cwd: sshRoutePayloadCwd(invocation.route),
|
||||
tty: false,
|
||||
stdinEotOnEnd: false,
|
||||
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
||||
runtimeTimeoutMs,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
};
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
|
||||
const script = [
|
||||
"set -eu",
|
||||
`payload=${shellQuote(payloadJson)}`,
|
||||
"if command -v backend-core >/dev/null 2>&1; then",
|
||||
' exec backend-core --ssh-broker "$payload"',
|
||||
"fi",
|
||||
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
||||
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
|
||||
'trap \'rm -f "$broker_js"\' EXIT',
|
||||
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
|
||||
'bun "$broker_js" "$payload"',
|
||||
].join("\n");
|
||||
const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (input !== undefined) {
|
||||
writeChunkedStdin(child.stdin, input);
|
||||
} else {
|
||||
child.stdin.end();
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
return await new Promise<SshCaptureResult>((resolve) => {
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | null = null;
|
||||
const finish = (exitCode: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(runtimeTimer);
|
||||
if (killTimer !== null) clearTimeout(killTimer);
|
||||
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
||||
invocation,
|
||||
transport: "backend-core-broker",
|
||||
exitCode,
|
||||
startedAtMs,
|
||||
}));
|
||||
if (timingHint) stderr += timingHint;
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
};
|
||||
const runtimeTimer = setTimeout(() => {
|
||||
const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
||||
invocation,
|
||||
transport: "backend-core-broker",
|
||||
timeoutMs: runtimeTimeoutMs,
|
||||
}));
|
||||
stderr += hint;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
killTimer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, 2000);
|
||||
finish(124);
|
||||
}, runtimeTimeoutMs);
|
||||
child.on("error", (error) => {
|
||||
stderr += `unidesk ssh failed to start broker: ${error.message}\n`;
|
||||
finish(255);
|
||||
});
|
||||
child.on("close", (code) => finish(code ?? 255));
|
||||
});
|
||||
}
|
||||
|
||||
function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
|
||||
const buffer = Buffer.from(input, "utf8");
|
||||
const chunkSize = 32 * 1024;
|
||||
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||
stdin.write(buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize)));
|
||||
}
|
||||
stdin.end();
|
||||
}
|
||||
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
const invocation = parseSshInvocation(providerId, args);
|
||||
const parsed = invocation.parsed;
|
||||
const operationName = args[0] ?? "";
|
||||
if (operationName === "v2") {
|
||||
const executor: ApplyPatchV2Executor = { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
|
||||
return await runApplyPatchV2({
|
||||
executor,
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
argv: args.slice(1),
|
||||
});
|
||||
}
|
||||
const startedAtMs = Date.now();
|
||||
const size = terminalSize();
|
||||
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
||||
@@ -2059,8 +2200,10 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
' exec backend-core --ssh-broker "$payload"',
|
||||
"fi",
|
||||
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
||||
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >/tmp/unidesk-ssh-broker.js`,
|
||||
"exec bun /tmp/unidesk-ssh-broker.js \"$payload\"",
|
||||
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
|
||||
'trap \'rm -f "$broker_js"\' EXIT',
|
||||
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
|
||||
'bun "$broker_js" "$payload"',
|
||||
].join("\n");
|
||||
const child = spawn("docker", [
|
||||
"exec",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 {
|
||||
@@ -35,6 +38,10 @@ function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void
|
||||
throw new Error(`${message}: expected throw`);
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex");
|
||||
}
|
||||
|
||||
function decodeWinEncodedCommand(remoteCommand: string | null | undefined): string {
|
||||
const text = String(remoteCommand ?? "");
|
||||
const match = /'-EncodedCommand' '([^']+)'/u.exec(text);
|
||||
@@ -71,7 +78,135 @@ function applyPatchFixture(args: string[], patch: string, files: Record<string,
|
||||
}
|
||||
}
|
||||
|
||||
export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
|
||||
const state = new Map(Object.entries(files));
|
||||
const commands: string[] = [];
|
||||
const stdin = new PassThrough();
|
||||
stdin.end(patch);
|
||||
let stdout = "";
|
||||
const stdoutSink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
let error: unknown | null = null;
|
||||
try {
|
||||
await runApplyPatchV2({
|
||||
stdin,
|
||||
stdout: stdoutSink,
|
||||
executor: {
|
||||
async run(command, input) {
|
||||
const operation = command[4] ?? "";
|
||||
const target = command[5] ?? "";
|
||||
commands.push([operation, ...command.slice(5)].join(" "));
|
||||
if (operation === "read") {
|
||||
if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` };
|
||||
return { exitCode: 0, stdout: state.get(target) ?? "", stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-argv") {
|
||||
const expectedBytes = Number(command[6] ?? "-1");
|
||||
const expectedSha256 = command[7] ?? "";
|
||||
const content = Buffer.from(command.slice(8).join(""), "base64").toString("utf8");
|
||||
if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) {
|
||||
return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" };
|
||||
}
|
||||
state.set(target, content);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "write-b64-stdin") {
|
||||
const expectedBytes = Number(command[6] ?? "-1");
|
||||
const expectedSha256 = command[7] ?? "";
|
||||
const content = Buffer.from(input ?? "", "base64").toString("utf8");
|
||||
if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) {
|
||||
return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" };
|
||||
}
|
||||
state.set(target, content);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "delete") {
|
||||
state.delete(target);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (operation === "move") {
|
||||
state.set(command[6] ?? "", state.get(target) ?? "");
|
||||
state.delete(target);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { exitCode: 2, stdout: "", stderr: "bad op" };
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
return { stdout, files: Object.fromEntries(state), commands, error };
|
||||
}
|
||||
|
||||
async function applyPatchV2ActualShellFixtureAttempt(
|
||||
patch: string,
|
||||
files: Record<string, string>,
|
||||
mutateInput?: (operation: string, input: string | undefined) => string | undefined,
|
||||
): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-v2-shell-"));
|
||||
const commands: string[] = [];
|
||||
const stdin = new PassThrough();
|
||||
stdin.end(patch);
|
||||
let stdout = "";
|
||||
const stdoutSink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const target = path.join(root, relativePath);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, "utf8");
|
||||
}
|
||||
let error: unknown | null = null;
|
||||
try {
|
||||
await runApplyPatchV2({
|
||||
stdin,
|
||||
stdout: stdoutSink,
|
||||
executor: {
|
||||
async run(command, input) {
|
||||
const operation = command[4] ?? "";
|
||||
commands.push([operation, ...command.slice(5)].join(" "));
|
||||
const run = spawnSync(command[0] ?? "sh", command.slice(1), {
|
||||
cwd: root,
|
||||
input: mutateInput ? mutateInput(operation, input) : input,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return {
|
||||
exitCode: run.status ?? 255,
|
||||
stdout: run.stdout,
|
||||
stderr: run.stderr,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
const outputFiles: Record<string, string> = {};
|
||||
for (const relativePath of Object.keys(files)) {
|
||||
outputFiles[relativePath] = readFileSync(path.join(root, relativePath), "utf8");
|
||||
}
|
||||
return { stdout, files: outputFiles, commands, error };
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPatchV2Fixture(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[] }> {
|
||||
const result = await applyPatchV2FixtureAttempt(patch, files);
|
||||
if (result.error !== null) throw result.error;
|
||||
return { stdout: result.stdout, files: result.files, commands: result.commands };
|
||||
}
|
||||
|
||||
export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
const argv = parseSshArgs(["argv", "true"]);
|
||||
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
|
||||
assertCondition(argv.remoteCommand === "'true'", "argv command must shell-quote each token", argv);
|
||||
@@ -129,6 +264,11 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(directScriptCommand.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- command form must preserve dash-prefixed command args", directScriptCommand);
|
||||
assertCondition(directScriptCommand.requiresStdin === false, "script -- command form must not wait for stdin", directScriptCommand);
|
||||
|
||||
const directScriptOneLiner = parseSshArgs(["script", "--", "cd /root/hwlab && git status --short --branch"]);
|
||||
assertCondition(directScriptOneLiner.invocationKind === "helper", "script -- single-string command should run through a remote shell", directScriptOneLiner);
|
||||
assertCondition(directScriptOneLiner.remoteCommand === "'sh' '-c' 'cd /root/hwlab && git status --short --branch'", "script -- single-string command should match the intuitive remote shell one-liner form", directScriptOneLiner);
|
||||
assertCondition(directScriptOneLiner.requiresStdin === false, "script -- single-string command should not wait for stdin", directScriptOneLiner);
|
||||
|
||||
const shellOneLiner = parseSshArgs(["shell", "sed -n '1,2p' a && sed -n '1,2p' b"]);
|
||||
assertCondition(shellOneLiner.invocationKind === "helper", "shell one-liner must be a helper operation", shellOneLiner);
|
||||
assertCondition(shellOneLiner.remoteCommand === "'sh' '-c' 'sed -n '\\''1,2p'\\'' a && sed -n '\\''1,2p'\\'' b'", "shell one-liner must keep command operators inside the remote shell", shellOneLiner);
|
||||
@@ -202,6 +342,191 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(hostApplyPatchLoose.requiredHelpers?.length === 1 && hostApplyPatchLoose.requiredHelpers.includes("apply_patch"), "host apply-patch must request only the apply_patch helper bootstrap", hostApplyPatchLoose);
|
||||
assertCondition(remoteApplyPatchSource.includes("replace_once_with_perl") && remoteApplyPatchSource.includes("perl -0777"), "apply_patch helper must keep a fast path for large files", {});
|
||||
|
||||
const hostApplyPatchV2 = parseSshArgs(["v2"]);
|
||||
assertCondition(hostApplyPatchV2.requiresStdin === true && hostApplyPatchV2.requiredHelpers === undefined && hostApplyPatchV2.remoteCommand === null, "host v2 must be a local engine operation, not a remote helper bootstrap", hostApplyPatchV2);
|
||||
const hostApplyPatchV2Help = parseSshArgs(["v2", "--help"]);
|
||||
assertCondition(hostApplyPatchV2Help.requiresStdin === false && hostApplyPatchV2Help.remoteCommand === null, "host v2 --help must not wait for patch stdin", hostApplyPatchV2Help);
|
||||
const podApplyPatchV2 = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["v2"]);
|
||||
assertCondition(podApplyPatchV2.parsed.requiresStdin === true && podApplyPatchV2.parsed.remoteCommand === null, "pod v2 must be handled by the local v2 engine instead of injecting the legacy helper", podApplyPatchV2);
|
||||
|
||||
const longChinesePatch = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: story.md",
|
||||
"@@",
|
||||
"+这是一个很长很长的中文段落,用来证明远端 v2 不再依赖 shell hunk 拼接和手写长中文 search block。它只是通过本地行级 patch engine 计算新内容,然后把完整文件写回远端,所以中文、标点、长句都不应该影响 patch 解析和匹配。",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"story.md": "开头\n",
|
||||
});
|
||||
assertCondition(longChinesePatch.files["story.md"]?.includes("很长很长的中文段落"), "v2 should accept pure insertion with long Chinese text", longChinesePatch);
|
||||
assertCondition(longChinesePatch.stdout.includes("Success. Updated the following files:"), "v2 must print visible success output", longChinesePatch);
|
||||
|
||||
const lowContextV1Baseline = applyPatchFixture([], [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: story.md",
|
||||
"@@",
|
||||
" 开头",
|
||||
"+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"story.md": "开头\n结尾\n",
|
||||
});
|
||||
assertCondition(lowContextV1Baseline.status !== 0 && lowContextV1Baseline.stderr.includes("insert-only without both leading and trailing context"), "v1 baseline should reject low-context pure insertion", lowContextV1Baseline);
|
||||
const lowContextV2 = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: story.md",
|
||||
"@@",
|
||||
" 开头",
|
||||
"+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"story.md": "开头\n结尾\n",
|
||||
});
|
||||
assertCondition(lowContextV2.files["story.md"]?.includes("v2 应该按 Codex 行级语义允许"), "v2 should fix v1 low-context insertion friction", lowContextV2);
|
||||
assertCondition(lowContextV2.commands.some((command) => command.includes("write-b64-argv")), "v2 should use argv write path for small remote files to work inside k3s pod exec capture", lowContextV2);
|
||||
|
||||
const unicodePunctuationV1Baseline = applyPatchFixture([], [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: notes.txt",
|
||||
"@@",
|
||||
"-alpha - beta",
|
||||
"+alpha - gamma",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"notes.txt": "alpha – beta\n",
|
||||
});
|
||||
assertCondition(unicodePunctuationV1Baseline.status !== 0, "v1 baseline should miss ASCII dash against typographic dash", unicodePunctuationV1Baseline);
|
||||
const unicodePunctuationV2 = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: notes.txt",
|
||||
"@@",
|
||||
"-alpha - beta",
|
||||
"+alpha - gamma",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"notes.txt": "alpha – beta\n",
|
||||
});
|
||||
assertCondition(unicodePunctuationV2.files["notes.txt"] === "alpha - gamma\n", "v2 should normalize common Unicode punctuation while matching expected lines", unicodePunctuationV2);
|
||||
|
||||
const repeatedBlockWithContext = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: repeated.txt",
|
||||
"@@ section two",
|
||||
"-marker",
|
||||
"+patched",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"repeated.txt": "section one\nmarker\nsection two\nmarker\n",
|
||||
});
|
||||
assertCondition(repeatedBlockWithContext.files["repeated.txt"] === "section one\nmarker\nsection two\npatched\n", "v2 should use @@ context to target repeated blocks", repeatedBlockWithContext);
|
||||
|
||||
const longChineseReplace = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: novel.md",
|
||||
"@@",
|
||||
"-林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。",
|
||||
"+林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙正在等待他重新命名。",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"novel.md": "林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。\n",
|
||||
});
|
||||
assertCondition(longChineseReplace.files["novel.md"]?.includes("等待他重新命名"), "v2 should replace long Chinese lines without remote shell search blocks", longChineseReplace);
|
||||
|
||||
const largeV2 = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: large.txt",
|
||||
"@@",
|
||||
"+large insert",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
|
||||
});
|
||||
assertCondition(largeV2.commands.some((command) => command.includes("write-b64-stdin")), "v2 should use stdin write path for large remote files to avoid E2BIG", largeV2.commands);
|
||||
|
||||
const multiChunkTailV2 = await applyPatchV2ActualShellFixtureAttempt([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: two_chunks.txt",
|
||||
"@@",
|
||||
"-b",
|
||||
"+B",
|
||||
"@@",
|
||||
"-d",
|
||||
"+D",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"two_chunks.txt": "a\nb\nc\nd\ne\nf\n",
|
||||
});
|
||||
assertCondition(multiChunkTailV2.error === null, "v2 should apply explicit multi-chunk patches through the real shell writer", multiChunkTailV2);
|
||||
assertCondition(multiChunkTailV2.files["two_chunks.txt"] === "a\nB\nc\nD\ne\nf\n", "v2 must preserve untouched tail lines when applying multiple chunks", multiChunkTailV2);
|
||||
|
||||
const truncatedLargeWriteV2 = await applyPatchV2ActualShellFixtureAttempt([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: large.txt",
|
||||
"@@",
|
||||
"+large insert that forces a rewritten full-file payload",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
|
||||
}, (operation, input) => {
|
||||
if (operation !== "write-b64-stdin" || input === undefined) return input;
|
||||
return input.slice(0, Math.max(0, input.length - 32));
|
||||
});
|
||||
assertCondition(truncatedLargeWriteV2.error !== null, "v2 should reject truncated stdin write payloads", truncatedLargeWriteV2);
|
||||
assertCondition(truncatedLargeWriteV2.files["large.txt"] === `${"0123456789abcdef\n".repeat(4096)}`, "v2 must keep the original file when decoded payload integrity fails", truncatedLargeWriteV2);
|
||||
assertCondition(
|
||||
String((truncatedLargeWriteV2.error as Error | null)?.message ?? "").includes("remote apply-patch v2 operation failed"),
|
||||
"v2 truncated payload failure should be visible to the caller",
|
||||
truncatedLargeWriteV2,
|
||||
);
|
||||
|
||||
const failedCompoundV2 = await applyPatchV2FixtureAttempt([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.txt",
|
||||
"@@",
|
||||
"-old first",
|
||||
"+new first",
|
||||
"*** Update File: second.txt",
|
||||
"@@",
|
||||
"-missing second",
|
||||
"+new second",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"first.txt": "old first\n",
|
||||
"second.txt": "old second\n",
|
||||
});
|
||||
assertCondition(failedCompoundV2.error !== null, "v2 compound patch should fail when a later hunk does not match", failedCompoundV2);
|
||||
assertCondition(failedCompoundV2.files["first.txt"] === "old first\n", "v2 must not partially write earlier files when a later hunk fails", failedCompoundV2);
|
||||
assertCondition(failedCompoundV2.files["second.txt"] === "old second\n", "v2 must leave later failed files unchanged", failedCompoundV2);
|
||||
assertCondition(!failedCompoundV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("delete")), "v2 must finish all local planning before any remote mutation", failedCompoundV2.commands);
|
||||
|
||||
const sequentialCompoundV2 = await applyPatchV2Fixture([
|
||||
"*** Begin Patch",
|
||||
"*** Update File: sequence.txt",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** Update File: sequence.txt",
|
||||
"@@",
|
||||
"-beta",
|
||||
"+gamma",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n"), {
|
||||
"sequence.txt": "alpha\n",
|
||||
});
|
||||
assertCondition(sequentialCompoundV2.files["sequence.txt"] === "gamma\n", "v2 should plan later hunks against earlier planned edits before remote writes", sequentialCompoundV2);
|
||||
|
||||
const safePatch = applyPatchFixture([], [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: sample.txt",
|
||||
@@ -411,6 +736,9 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "host apply-patch must bootstrap the remote apply_patch helper", frontendRemoteHostPatchPlan);
|
||||
assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/apply_patch") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/glob") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/skill-discover"), "host apply-patch must not bootstrap unrelated helper tools", frontendRemoteHostPatchPlan);
|
||||
|
||||
const frontendRemoteV2Plan = remoteSshFrontendPlanForTest("D601:/tmp", ["v2"]);
|
||||
assertCondition(frontendRemoteV2Plan.requiresStdin === true && frontendRemoteV2Plan.remoteCommand === null && !String(frontendRemoteV2Plan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR"), "frontend v2 plan must stay a local engine operation and not bootstrap legacy helpers", frontendRemoteV2Plan);
|
||||
|
||||
const frontendRemotePodArgvPlan = remoteSshFrontendPlanForTest("G14:k3s:unidesk:code-queue", ["argv", "sh", "-c", "command -v tran"]);
|
||||
assertCondition(frontendRemotePodArgvPlan.providerId === "G14", "remote frontend pod route must dispatch through G14 provider", frontendRemotePodArgvPlan);
|
||||
assertCondition(frontendRemotePodArgvPlan.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'unidesk' 'deployment/code-queue' '--' 'sh' '-c' 'command -v tran'", "remote frontend pod argv route must be fully assembled before dispatch", frontendRemotePodArgvPlan);
|
||||
@@ -454,7 +782,7 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
checks: [
|
||||
"argv form is classified and quoted as the success path for non-interactive commands",
|
||||
"stdin script form removes shell-command strings for host and k3s workload scripts",
|
||||
"script -- command form executes dash-prefixed argv without waiting for stdin",
|
||||
"script -- single-string runs as a remote shell one-liner while multi-token form keeps dash-prefixed argv",
|
||||
"pod apply-patch operation injects helper and forwards patch stdin",
|
||||
"pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping",
|
||||
"apply-patch uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit",
|
||||
@@ -480,5 +808,6 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.stdout.write(`${JSON.stringify(runSshArgvGuidanceContract(), null, 2)}\n`);
|
||||
const result = await runSshArgvGuidanceContract();
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user