fix: preserve legacy text across trans helpers

This commit is contained in:
Codex
2026-07-10 19:41:44 +02:00
parent d07830dd17
commit 9f8e325ea5
8 changed files with 253 additions and 64 deletions
+16 -3
View File
@@ -29,15 +29,28 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md
- 远端文本修改优先 `trans <route> apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。
- Host/WSL 与 Windows route 的 `apply-patch` 优先走 fs adapter bulk update path;不要为了规避旧的 `read-b64-block` / `write-b64-argv` 慢路径改用临时脚本写文件。
- `apply-patch v2` 的 localized replacement 必须保留纯 CRLF 文件的 CRLF 风格;original/final integrity mismatch 会重新只读采集当前文件并输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding 摘要,不披露文件正文,也不回退 `apply-patch-v1`
- `apply-patch v2` 字节与完整性边界:
- localized replacement 必须保留纯 CRLF 和 `legacy-single-byte` 文件的原始字节风格。
- legacy 文件只允许可保持的单字节 replacement。
- original/final integrity mismatch 会重新只读采集当前文件并输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding 摘要。
- 诊断不披露文件正文,也不回退 `apply-patch-v1`
- 当前 HWLAB node/lane source workspace 的正式预检/同步入口是 `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm``source-workspace status`;不要把裸 `trans <node>:<workspace> git fetch ...` 当成当前 HWLAB node/lane workspace 预检/修复入口。
- `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。
- 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。
- `/mnt/<drive>` WSL host workspace 由 root 透传执行时,CLI 自动把 workspace owner uid 桥接到进程级 `SUDO_UID`,让 Git 信任 Windows 挂载目录而不写全局 `safe.directory`;非 Git 命令和普通 Linux workspace 不改变所有权信任。
- Windows route 的 `win` 是 route planeoperation 直接写 `ps``cmd``git` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要写成 `trans D601:win/... win ps`,也不要把 POSIX shell 当 Windows shell。
- Windows `rg` 调用 Windows PATH 中的原生 `rg.exe`,支持 `--files``-g|--glob``--max-count``--max-files``--timeout-ms`stderr 固定输出 `UNIDESK_WINDOWS_RG_SUMMARY`,其中包含 `matched``fileCount``elapsedMs``timeout`,默认预算来自 `config/unidesk-cli.yaml#trans.windowsFs.rg`。超时额外输出 `UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob`;缺少 `rg.exe` 时明确返回 127 和安装/PowerShell 替代提示。
- Windows `rg`
- 调用 Windows PATH 中的原生 `rg.exe`
- 支持一个或多个 `-e|--regexp``--files``-g|--glob``--max-count``--max-files``--timeout-ms`
- stderr 固定输出 `UNIDESK_WINDOWS_RG_SUMMARY`,包含 `matched``fileCount``elapsedMs``timeout``encoding`
- 默认预算来自 `config/unidesk-cli.yaml#trans.windowsFs.rg`
- 超时输出 `UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob`
- 缺少 `rg.exe` 时返回 127 和安装或 PowerShell 替代提示。
- Windows `wc` 支持 `-l|--lines``-w|--words``-m|--chars``-c|--bytes``skills` 支持 `--name <精确名称>``--filter <文本>`,单个 skill 查询不应先拉取全量列表。
- Windows `head` / `tail` 支持 `-n N``-nN``--lines=N` 和 GNU 简写 `-N`PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8,读取 UTF-8 Markdown 时应与 `cat` helper 保持一致。
- Windows `head` / `tail`
- 支持 `-n N``-nN``--lines=N` 和 GNU 简写 `-N`
- 两者按行流式裁剪,不能在裁剪前因整文件超过通用读取门禁而失败。
- PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8,读取 UTF-8 Markdown 时应与 `cat` helper 保持一致。
- Windows `ps '<PowerShell source>'` 的单个参数按 PowerShell 源码执行;`ps <command> <arg...>` 的多个参数按 argv 边界执行,Windows native application 通过显式命令行引用保留 `python -c` 代码、路径、encoding 和字典 key 中的嵌套引号。
- `trans` / `tran` 是 PATH wrapper,不是 `bun scripts/cli.ts` 的根子命令;误写 `bun scripts/cli.ts trans ...` 时应读取结构化 `trans-root-cli-entrypoint-moved` 错误,并改用 `trans <route> <operation> [args...]`
- Direct argv 会保留调用端已经形成的每个 argv token,包括空格和中文;需要 shell 语法时仍显式使用 `sh``bash`、Windows `cmd``ps`
+15 -1
View File
@@ -443,7 +443,21 @@ PS
`<provider>:win skills [--scope agents|codex|all] [--limit N] [--name <精确名称>|--filter <文本>]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts`、`query` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。查单个 skill 时优先 `--name`,模糊查找时使用 `--filter`,避免全量结果触发 stdout dump;需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`。
`<provider>:win/<drive>/<path> rg` 是有界 Windows 文件搜索入口,调用 Windows PATH 中的原生 `rg.exe`,支持 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`。每次搜索都在 stderr 输出 `UNIDESK_WINDOWS_RG_SUMMARY`,稳定披露 `matched`、`fileCount`、`elapsedMs`、`timeout` 和截断状态;默认预算由 `config/unidesk-cli.yaml#trans.windowsFs.rg` 唯一控制。超时返回 124,无匹配返回 1,缺少 `rg.exe` 返回 127 和可执行替代提示,均不会以空输出伪装成功。Windows `wc` 同时支持 `-l|--lines`、`-w|--words`、`-m|--chars` 与 `-c|--bytes`。
- Windows `rg`
- `<provider>:win/<drive>/<path> rg` 调用 Windows PATH 中的原生 `rg.exe`。
- 支持一个或多个 `-e|--regexp`,以及 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`。
- 搜索强制输出文件名,`UNIDESK_WINDOWS_RG_SUMMARY` 披露 `matched`、`fileCount`、`elapsedMs`、`timeout`、`encoding` 和截断状态。
- legacy 或无效 UTF-8 输出必须标记 `encoding=legacy-or-invalid-utf8`,不能静默当作 UTF-8 正常文本。
- 默认预算由 `config/unidesk-cli.yaml#trans.windowsFs.rg` 唯一控制。
- 超时返回 124,无匹配返回 1,缺少 `rg.exe` 返回 127 和可执行替代提示。
- Windows 有界读取:
- `wc` 支持 `-l|--lines`、`-w|--words`、`-m|--chars` 与 `-c|--bytes`。
- `head` 和 `tail` 在行裁剪阶段使用流式读取,不得先以整文件大小门禁拒绝已经有界的 `-n N` 请求。
- `apply-patch v2` legacy 文本:
- bulk read、plan 和 apply 必须共享同一原始字节 fingerprint。
- 非 UTF-8 单字节文本标记为 `legacy-single-byte`。
- ASCII localized replacement 必须保持原 legacy 字节和换行风格。
- legacy replacement 包含无法用单字节保持的新字符时必须显式拒绝,不能转码整个文件。
`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,标准 workload 段写成 `pod:<podid>`,例如 `D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api`。`pod/<podid>` 不是合法 route 写法,因为 `:` 是分布式路由分隔符,`/` 只表示目标容器里的文件系统 cwd;如果调用者写成 `pod/<podid>/<container>`,CLI 必须在连接运行面前报错并提示改用 `pod:<podid>:<container>` 或 `--container <container>`。若目标是 Deployment,也可以显式写 `deployment:<name>` 或简写 `<name>`;容器选择写 `:<container>` 或 operation 参数 `--container <container>`。pod 内 cwd 推荐用 operation 参数 `--cwd /path`,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd`。`kubectl`、`logs`、`sh`、`bash`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
+44
View File
@@ -167,6 +167,50 @@ describe("apply-patch v2 fs bulk update path", () => {
}
});
test("preserves legacy single-byte content during ASCII localized replacement", async () => {
const original = Buffer.concat([
Buffer.from("static int old_value = 1;\r\n// legacy ", "ascii"),
Buffer.from([0xca]),
Buffer.from(" comment\r\n", "ascii"),
]);
let captured: ApplyPatchV2BulkReplacementWritePlan | undefined;
const fs: ApplyPatchV2FileSystem = {
async stat() { throw new Error("stat should not be used"); },
async readBlock() { throw new Error("readBlock should not be used"); },
async writeFile() { throw new Error("writeFile should not be used"); },
async deleteFile() { throw new Error("deleteFile should not be used"); },
async readFiles(paths) {
return new Map(paths.map((path) => [path, {
content: original.toString("latin1"),
bytes: original,
encodingStyle: "legacy-single-byte" as const,
}]));
},
async applyReplacementsBulk(_paths, plans) {
captured = plans.get("src/legacy.c");
},
};
const exitCode = await runApplyPatchV2({
executor: { fs },
stdin: Readable.from([[
"*** Begin Patch",
"*** Update File: src/legacy.c",
"@@",
"-static int old_value = 1;",
"+static int new_value = 2;",
"*** End Patch",
].join("\n")]),
stdout: new CaptureWritable(),
stderr: new CaptureWritable(),
});
expect(exitCode).toBe(0);
expect(captured?.encodingStyle).toBe("legacy-single-byte");
expect(captured?.newlineStyle).toBe("crlf");
expect(captured?.originalBytes).toBe(original.length);
expect(captured?.originalSha256).toBe(createHash("sha256").update(original).digest("hex"));
});
test("bulk read failure falls back to block reads but still writes through replacement bulk", async () => {
const files = new Map<string, string>([
["a.md", "first\nold-a\nlast\n"],
+78 -31
View File
@@ -63,10 +63,16 @@ export interface ApplyPatchV2FileSystem {
readBlock(path: string, blockIndex: number, blockBytes: number): Promise<Buffer>;
writeFile(path: string, content: Buffer): Promise<void>;
deleteFile(path: string): Promise<void>;
readFiles?(paths: string[]): Promise<Map<string, string>>;
readFiles?(paths: string[]): Promise<Map<string, ApplyPatchV2ReadFile | string>>;
applyReplacementsBulk?(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>): Promise<void>;
}
export interface ApplyPatchV2ReadFile {
content: string;
bytes: Buffer;
encodingStyle: "utf8" | "utf8-bom" | "legacy-single-byte";
}
export interface ApplyPatchV2FileStat {
bytes: number;
sha256: string;
@@ -114,7 +120,7 @@ export class ApplyPatchV2Error extends Error {
}
}
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
type PlannedFileState = { exists: true; content: string; bytes: Buffer; encodingStyle: ApplyPatchV2ReadFile["encodingStyle"] } | { exists: false; content: "" };
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
export type ApplyPatchV2Replacement = [start: number, oldLength: number, newLines: string[]];
type Replacement = ApplyPatchV2Replacement;
@@ -125,7 +131,7 @@ export interface ApplyPatchV2BulkReplacementWritePlan {
finalBytes: number;
finalSha256: string;
newlineStyle: "lf" | "crlf" | "mixed" | "none";
encodingStyle: "utf8" | "utf8-bom";
encodingStyle: ApplyPatchV2ReadFile["encodingStyle"];
replacements: ApplyPatchV2Replacement[];
}
type BulkReplacementWritePlan = ApplyPatchV2BulkReplacementWritePlan;
@@ -653,7 +659,7 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks
const paths = uniqueStrings(hunks.map((hunk) => hunk.path));
const originalFiles = await readRemoteTextsBulk(executor, paths);
const states = new Map<string, PlannedFileState>();
for (const [filePath, content] of originalFiles) states.set(filePath, { exists: true, content });
for (const [filePath, file] of originalFiles) states.set(filePath, { exists: true, ...file });
const changed: string[] = [];
const outcomes: ApplyPatchV2Outcome[] = [];
@@ -667,12 +673,12 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks
const state = states.get(hunk.path);
if (state === undefined || !state.exists) throw new ApplyPatchV2Error("cannot update a missing file in bulk patch", { path: hunk.path });
const originalLines = splitContentLines(state.content);
const textFormat = detectApplyPatchTextFormat(state.content);
const textFormat = detectApplyPatchTextFormat(state.content, state.encodingStyle);
const replacements = preserveReplacementNewlines(computeReplacements(hunk.path, originalLines, hunk.chunks), textFormat.newlineStyle);
const newContent = joinLinesWithFinalNewline(applyReplacements(originalLines, replacements));
const originalBuffer = Buffer.from(state.content, "utf8");
const finalBuffer = Buffer.from(newContent, "utf8");
states.set(hunk.path, { exists: true, content: newContent });
const originalBuffer = state.bytes;
const finalBuffer = encodeApplyPatchText(newContent, state.encodingStyle, hunk.path);
states.set(hunk.path, { exists: true, content: newContent, bytes: finalBuffer, encodingStyle: state.encodingStyle });
replacementPlans.set(hunk.path, {
path: hunk.path,
originalBytes: originalBuffer.length,
@@ -1033,7 +1039,7 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P
return contentBuffer.toString("utf8");
}
async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise<Map<string, string>> {
async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise<Map<string, ApplyPatchV2ReadFile>> {
if (targets.length === 0) return new Map();
if (executor.fs?.readFiles !== undefined) {
try {
@@ -1041,14 +1047,14 @@ async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: stri
for (const target of targets) {
if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 fs bulk read omitted target", { target });
}
return files;
return new Map(Array.from(files, ([path, file]) => [path, normalizeApplyPatchV2ReadFile(file)]));
} catch (error) {
if (!isBulkReadFailure(error)) throw error;
}
}
if (executor.fs || targets.length === 1) {
const files = new Map<string, string>();
for (const target of targets) files.set(target, await readRemoteText(executor, target));
const files = new Map<string, ApplyPatchV2ReadFile>();
for (const target of targets) files.set(target, decodeApplyPatchV2ReadFile(Buffer.from(await readRemoteText(executor, target), "utf8")));
return files;
}
const result = await checkedRemoteV2(executor, "read-bulk-b64", targets);
@@ -1099,7 +1105,7 @@ function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: numbe
return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded;
}
export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): Map<string, string> {
export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): Map<string, ApplyPatchV2ReadFile> {
const lines = stdout.split(/\r?\n/u);
const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteBulkReadMarker} `));
if (markerIndex < 0) {
@@ -1113,7 +1119,7 @@ export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): M
if (records.length !== declaredCount) {
throw new ApplyPatchV2Error("remote apply-patch v2 bulk read record count mismatch", { expectedTargets: targets.length, records: records.length });
}
const files = new Map<string, string>();
const files = new Map<string, ApplyPatchV2ReadFile>();
for (const record of records) {
const [pathB64, bytesText, sha256, contentB64] = record.split(/\s+/u);
const target = Buffer.from(pathB64, "base64").toString("utf8");
@@ -1132,7 +1138,7 @@ export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): M
if (actualSha256 !== sha256) {
throw new ApplyPatchV2Error(`remote apply-patch v2 bulk read sha256 mismatch for ${target}`, { target, expectedSha256: sha256, actualSha256 });
}
files.set(target, contentBuffer.toString("utf8"));
files.set(target, decodeApplyPatchV2ReadFile(contentBuffer));
}
for (const target of targets) {
if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 bulk read omitted target", { target });
@@ -1188,7 +1194,7 @@ async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPa
const details = error instanceof ApplyPatchV2Error ? error.details : {};
const serialized = `${message}\n${JSON.stringify(details)}`;
if (fs.readFiles === undefined || !serialized.includes("integrity mismatch")) return error instanceof ApplyPatchV2Error ? error : new ApplyPatchV2Error(message);
let currentFiles: Map<string, string>;
let currentFiles: Map<string, ApplyPatchV2ReadFile | string>;
try {
currentFiles = await fs.readFiles(targets);
} catch (readError) {
@@ -1202,10 +1208,11 @@ async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPa
const adapter = route.includes(":win") ? "windows" : "posix";
const integrityDiagnostics = targets.flatMap((target) => {
const plan = plans.get(target);
const current = currentFiles.get(target);
if (plan === undefined || current === undefined) return [];
const currentBuffer = Buffer.from(current, "utf8");
const currentFormat = detectApplyPatchTextFormat(current);
const currentValue = currentFiles.get(target);
if (plan === undefined || currentValue === undefined) return [];
const current = normalizeApplyPatchV2ReadFile(currentValue);
const currentBuffer = current.bytes;
const currentFormat = detectApplyPatchTextFormat(current.content, current.encodingStyle);
return [{
adapter,
stage,
@@ -1245,6 +1252,7 @@ export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable<string>
plan.originalSha256,
String(plan.finalBytes),
plan.finalSha256,
plan.encodingStyle,
replacementFields.join(";"),
].join(" "));
}
@@ -1364,11 +1372,14 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
" python3 - \"$tmp_payload\" \"$tmp_dir\" \"$expected_count\" <<'PY'",
"import base64, hashlib, pathlib, shutil, sys",
"def text_profile(source):",
" encoding = 'utf8-bom' if source.startswith(b'\\xef\\xbb\\xbf') else 'utf8'",
" text = source.decode('utf-8')",
" crlf = text.count('\\r\\n')",
" bare_lf = text.count('\\n') - crlf",
" lone_cr = text.count('\\r') - crlf",
" try:",
" source.decode('utf-8')",
" encoding = 'utf8-bom' if source.startswith(b'\\xef\\xbb\\xbf') else 'utf8'",
" except UnicodeDecodeError:",
" encoding = 'legacy-single-byte'",
" crlf = source.count(b'\\r\\n')",
" bare_lf = source.count(b'\\n') - crlf",
" lone_cr = source.count(b'\\r') - crlf",
" newline = 'crlf' if crlf > 0 and bare_lf == 0 and lone_cr == 0 else ('lf' if bare_lf > 0 and crlf == 0 and lone_cr == 0 else ('none' if crlf == 0 and bare_lf == 0 and lone_cr == 0 else 'mixed'))",
" return encoding, newline",
"def integrity_failure(adapter, stage, target, expected_bytes, expected_sha256, source):",
@@ -1384,16 +1395,17 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
"plans = []",
"for index, record in enumerate(records, start=1):",
" fields = record.split()",
" if len(fields) != 6:",
" if len(fields) != 7:",
" raise SystemExit('bulk replacement malformed record')",
" path_b64, original_bytes_text, original_sha256, final_bytes_text, final_sha256, replacements_text = fields",
" path_b64, original_bytes_text, original_sha256, final_bytes_text, final_sha256, encoding_style, replacements_text = fields",
" target = base64.b64decode(path_b64).decode('utf-8')",
" original_bytes = int(original_bytes_text)",
" final_bytes = int(final_bytes_text)",
" source = pathlib.Path(target).read_bytes()",
" if len(source) != original_bytes or hashlib.sha256(source).hexdigest() != original_sha256:",
" integrity_failure('posix', 'original', target, original_bytes, original_sha256, source)",
" lines = source.decode('utf-8').split('\\n')",
" source_encoding = 'latin-1' if encoding_style == 'legacy-single-byte' else 'utf-8'",
" lines = source.decode(source_encoding).split('\\n')",
" if lines and lines[-1] == '':",
" lines.pop()",
" replacements = []",
@@ -1410,7 +1422,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
" if start < 0 or old_len < 0 or start + old_len > len(lines):",
" raise SystemExit(f'bulk replacement out of bounds for {target}')",
" lines[start:start + old_len] = new_lines",
" output = (('\\n'.join(lines) + '\\n') if lines else '').encode('utf-8')",
" output = (('\\n'.join(lines) + '\\n') if lines else '').encode(source_encoding)",
" if len(output) != final_bytes or hashlib.sha256(output).hexdigest() != final_sha256:",
" integrity_failure('posix', 'final', target, final_bytes, final_sha256, output)",
" tmp_path = pathlib.Path(tmp_dir) / f'file-{index}.tmp'",
@@ -1636,7 +1648,7 @@ function splitContentLines(content: string): string[] {
return lines;
}
function detectApplyPatchTextFormat(content: string): Pick<ApplyPatchV2BulkReplacementWritePlan, "newlineStyle" | "encodingStyle"> {
function detectApplyPatchTextFormat(content: string, encodingStyle: ApplyPatchV2ReadFile["encodingStyle"] = content.startsWith("\uFEFF") ? "utf8-bom" : "utf8"): Pick<ApplyPatchV2BulkReplacementWritePlan, "newlineStyle" | "encodingStyle"> {
const crlfCount = (content.match(/\r\n/gu) ?? []).length;
const bareLfCount = (content.match(/(?<!\r)\n/gu) ?? []).length;
const loneCrCount = (content.match(/\r(?!\n)/gu) ?? []).length;
@@ -1649,10 +1661,45 @@ function detectApplyPatchTextFormat(content: string): Pick<ApplyPatchV2BulkRepla
: "mixed";
return {
newlineStyle,
encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8",
encodingStyle,
};
}
function decodeApplyPatchV2ReadFile(contentBuffer: Buffer): ApplyPatchV2ReadFile {
try {
const content = new TextDecoder("utf-8", { fatal: true }).decode(contentBuffer);
return {
content,
bytes: contentBuffer,
encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8",
};
} catch {
return {
content: contentBuffer.toString("latin1"),
bytes: contentBuffer,
encodingStyle: "legacy-single-byte",
};
}
}
function normalizeApplyPatchV2ReadFile(file: ApplyPatchV2ReadFile | string): ApplyPatchV2ReadFile {
return typeof file === "string" ? decodeApplyPatchV2ReadFile(Buffer.from(file, "utf8")) : file;
}
function encodeApplyPatchText(content: string, encodingStyle: ApplyPatchV2ReadFile["encodingStyle"], path: string): Buffer {
if (encodingStyle !== "legacy-single-byte") return Buffer.from(content, "utf8");
for (const character of content) {
if ((character.codePointAt(0) ?? 0) > 0xff) {
throw new ApplyPatchV2Error("legacy single-byte localized replacement only supports byte-preserving text", {
path,
encodingStyle,
replacementRequirement: "ASCII or existing single-byte characters",
});
}
}
return Buffer.from(content, "latin1");
}
function preserveReplacementNewlines(replacements: Replacement[], newlineStyle: ApplyPatchV2BulkReplacementWritePlan["newlineStyle"]): Replacement[] {
if (newlineStyle !== "crlf") return replacements;
return replacements.map(([start, oldLength, newLines]) => [
+36 -1
View File
@@ -860,7 +860,7 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
const arg1 = args[1] ?? "";
const arg2 = args[2] ?? "";
const arg3 = args[3] ?? "";
return [
const scriptParts = [
"$ErrorActionPreference = 'Stop';",
"$ProgressPreference = 'SilentlyContinue';",
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
@@ -895,6 +895,41 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
" 'delete' { Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue; break }",
" default { Fail ('unsupported apply-patch fs op: ' + $operation) 2 }",
"}",
];
return scriptParts
.map((line) => line.startsWith(" 'apply-replacements-bulk-stdin' ") ? windowsBulkReplacementScript() : line)
.join(" ");
}
function windowsBulkReplacementScript(): string {
return [
" 'apply-replacements-bulk-stdin' {",
" $expectedCount = [Int32]$targetArg;",
" $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) });",
" if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 };",
" $plans = New-Object System.Collections.Generic.List[object];",
" foreach ($record in $records) {",
" $fields = $record -split '\\s+'; if ($fields.Count -ne 7) { Fail 'bulk replacement malformed record' 23 };",
" $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative;",
" $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $encodingStyle = $fields[5]; $replacementsText = $fields[6];",
" if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 };",
" $source = [System.IO.File]::ReadAllBytes($resolved); $actualOriginalBytes = [Int64]$source.Length; $actualOriginalSha256 = Get-Sha256Bytes $source;",
" if ($actualOriginalBytes -ne $originalBytes) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
" if (-not [string]::Equals($actualOriginalSha256, $originalSha256, [StringComparison]::OrdinalIgnoreCase)) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
" $textEncoding = if ($encodingStyle -eq 'legacy-single-byte') { [System.Text.Encoding]::GetEncoding(28591) } else { [System.Text.UTF8Encoding]::new($false, $true) };",
" try { $sourceText = $textEncoding.GetString($source) } catch { Fail ('bulk replacement source decode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
" $lines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) };",
" $replacements = New-Object System.Collections.Generic.List[object];",
" if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } };",
" foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; if ($replacement.oldLength -gt 0) { $lines.RemoveRange($replacement.start, $replacement.oldLength) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } };",
" $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") };",
" try { $outputBytes = $textEncoding.GetBytes($outputText) } catch { Fail ('bulk replacement final encode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
" if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 };",
" $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllBytes($tmp, $outputBytes); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null;",
" };",
" foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } };",
" break",
" }",
].join(" ");
}
+21 -2
View File
@@ -10,7 +10,26 @@ if ($operation -in @('head', 'tail')) {
$paths.Add($arg)
}
if ($paths.Count -ne 1) { Fail ($operation + ' requires exactly one file path on Windows routes') 2 }
$lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576)
if ($operation -eq 'head') { Print-Lines ([string[]]($lines | Select-Object -First $linesWanted)) } else { Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted)) }
$resolved = Resolve-UnideskPath $paths[0]
Need-File $resolved
$selected = [Collections.Generic.List[string]]::new()
$reader = [IO.StreamReader]::new($resolved, [Text.UTF8Encoding]::new($false, $true), $true)
try {
if ($operation -eq 'head') {
while ($selected.Count -lt $linesWanted -and -not $reader.EndOfStream) { $selected.Add($reader.ReadLine()) }
} else {
$queue = [Collections.Generic.Queue[string]]::new()
while (-not $reader.EndOfStream) {
$queue.Enqueue($reader.ReadLine())
if ($queue.Count -gt $linesWanted) { [void]$queue.Dequeue() }
}
foreach ($line in $queue) { $selected.Add($line) }
}
} catch {
Fail ('file is not valid UTF-8: ' + $resolved + ': ' + $_.Exception.Message) 25
} finally {
$reader.Dispose()
}
Print-Lines ([string[]]$selected)
exit 0
}
+28 -25
View File
@@ -1,30 +1,32 @@
if ($operation -ne 'rg') { Fail ('unsupported Windows fs operation: ' + $operation) 2 }
$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $pattern = $null; $endOptions = $false
$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $endOptions = $false
$maxCount = [int]$rgPolicy.maxMatches; $maxFiles = [int]$rgPolicy.maxFiles; $maxBytes = [int]$rgPolicy.maxBytesPerFile; $timeoutMs = [int]$rgPolicy.timeoutMs
$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new()
$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new(); $patterns = [Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
$arg = $toolArgs[$i]
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-n', '--line-number')) { continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 10000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--max-files' $arg.Substring(12) 50000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 }
if ($filesOnly) { $paths.Add($arg) } elseif ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) }
if (-not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }
if (-not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue }
if (-not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue }
if (-not $endOptions -and $arg -in @('-n', '--line-number')) { continue }
if (-not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue }
if (-not $endOptions -and $arg -in @('-e', '--regexp')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $patterns.Add($toolArgs[$i]); continue }
if (-not $endOptions -and $arg.StartsWith('--regexp=')) { $patterns.Add($arg.Substring(9)); continue }
if (-not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue }
if (-not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue }
if (-not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
if (-not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue }
if (-not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 10000; continue }
if (-not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue }
if (-not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--max-files' $arg.Substring(12) 50000; continue }
if (-not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
if (-not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue }
if (-not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue }
if (-not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue }
if (-not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -e/--regexp -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 }
if ($filesOnly) { $paths.Add($arg) } elseif ($patterns.Count -eq 0) { $patterns.Add($arg) } else { $paths.Add($arg) }
}
if (-not $filesOnly -and $null -eq $pattern) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 }
if (-not $filesOnly -and $patterns.Count -eq 0) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 }
if ($paths.Count -eq 0) { $paths.Add('.') }
$nativeRg = Get-Command rg.exe -ErrorAction SilentlyContinue | Select-Object -First 1
@@ -36,12 +38,12 @@ if ($null -ne $nativeRg) {
return $Value
}
$nativeArgs = [Collections.Generic.List[string]]::new()
$nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number')
$nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number'); $nativeArgs.Add('--with-filename')
$nativeArgs.Add('--max-filesize'); $nativeArgs.Add([string]$maxBytes)
if ($ignoreCase) { $nativeArgs.Add('--ignore-case') }
if ($fixed) { $nativeArgs.Add('--fixed-strings') }
foreach ($glob in $globs) { $nativeArgs.Add('--glob'); $nativeArgs.Add($glob) }
if ($filesOnly) { $nativeArgs.Add('--files') } else { $nativeArgs.Add($pattern) }
if ($filesOnly) { $nativeArgs.Add('--files') } else { foreach ($item in $patterns) { $nativeArgs.Add('--regexp'); $nativeArgs.Add($item) } }
foreach ($raw in $paths) { $nativeArgs.Add((Resolve-UnideskPath $raw)) }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $nativeRg.Source
@@ -59,9 +61,10 @@ if ($null -ne $nativeRg) {
if (-not [string]::IsNullOrWhiteSpace($stderrText)) { [Console]::Error.Write($stderrText) }
$truncated = $lines.Count -gt $selected.Count
$matchedFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^([A-Za-z]:\\.*?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } }
if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^(.+?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } }
$fileCount = if ($filesOnly) { $selected.Count } else { $matchedFiles.Count }
[Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg')
$encodingState = if ($stdoutText.Contains([char]0xfffd)) { 'legacy-or-invalid-utf8' } else { 'utf8-or-ascii' }
[Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' encoding=' + $encodingState + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg')
if ($timedOut) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob message=retry-with-a-narrower-path-or-g/--glob'); exit 124 }
if ($process.ExitCode -ne 0) { exit $process.ExitCode }
exit 0
+15 -1
View File
@@ -103,6 +103,20 @@ describe("ssh windows fs read-only operations", () => {
expect(rgScript).toContain("config/unidesk-cli.yaml#trans.windowsFs.rg");
expect(rgScript).toContain("--files");
expect(rgScript).toContain("--glob");
expect(rgScript).toContain("--with-filename");
expect(rgScript).toContain("encoding=");
});
test("supports repeated rg patterns and streams large-file tail", () => {
const rgScript = windowsFsReadOnlyScript("F:\\Work\\demo", "rg", ["-n", "-e", "alpha", "--regexp", "beta", "src/test.c"]);
const tailScript = windowsFsReadOnlyScript("F:\\Work\\demo", "tail", ["-n", "40", ".state/request_history.jsonl"]);
expect(rgScript).toContain("$patterns.Add($toolArgs[$i])");
expect(rgScript).toContain("$nativeArgs.Add('--regexp')");
expect(rgScript).toContain("fileCount=");
expect(tailScript).toContain("[IO.StreamReader]::new");
expect(tailScript).toContain("[Collections.Generic.Queue[string]]::new()");
expect(tailScript).not.toContain("Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576");
});
test("supports wc flags, exact skill lookup, and Windows cmd argv boundaries", () => {
@@ -236,7 +250,7 @@ describe("ssh host apply-patch fs backend", () => {
},
]]));
expect(files.get("docs/test.md")).toBe(text);
expect(files.get("docs/test.md")?.content).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"]);