Files
openclaw/src/agents/apply-patch.ts
Yuval Dinodia 8ce1e18d2f fix(agents): apply_patch rewrites bytes on hunk context lines (#116128)
* fix(agents): apply_patch rewrites bytes on hunk context lines

A fuzzy apply_patch update replaced the entire matched span with the
model-authored patch text, so trailing whitespace, typographic punctuation,
and tab indentation on lines the hunk marked as context were overwritten
while the tool reported plain success.

The parser now records which emitted lines came in as context and which old
line each one came from, and the update applier keeps the file's own bytes
for those lines. Added and removed lines are still written from the patch.

* test(agents): cover apply_patch context preservation

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-30 09:04:04 +08:00

674 lines
19 KiB
TypeScript

/**
* Runtime apply_patch tool and parser.
* Parses OpenAI-style patch envelopes and applies add/update/delete/move hunks
* through guarded host or sandbox filesystem operations.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "typebox";
import { createAbortError } from "../infra/abort-signal.js";
import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import {
type ApplyPatchFileOptions,
createPatchTarget,
type PatchFileOps,
resolvePatchFileOps,
type SandboxApplyPatchConfig,
} from "./apply-patch-file-ops.js";
import { applyUpdateHunk } from "./apply-patch-update.js";
import type { MemoryWriteProvenanceObserver } from "./memory-write-provenance.js";
import { resolvePathFromInput } from "./path-policy.js";
import type { AgentTool } from "./runtime/index.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import {
withFileMutationQueue,
withFileMutationQueues,
} from "./sessions/tools/file-mutation-queue.js";
const BEGIN_PATCH_MARKER = "*** Begin Patch";
const END_PATCH_MARKER = "*** End Patch";
const ADD_FILE_MARKER = "*** Add File: ";
const DELETE_FILE_MARKER = "*** Delete File: ";
const UPDATE_FILE_MARKER = "*** Update File: ";
const MOVE_TO_MARKER = "*** Move to: ";
const EOF_MARKER = "*** End of File";
const CHANGE_CONTEXT_MARKER = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
type AddFileHunk = {
kind: "add";
path: string;
contents: string;
};
type DeleteFileHunk = {
kind: "delete";
path: string;
};
type UpdateFileChunk = {
changeContext?: string;
oldLines: string[];
newLines: string[];
contextOldIndexes: Array<number | undefined>;
isEndOfFile: boolean;
};
type UpdateFileHunk = {
kind: "update";
path: string;
movePath?: string;
chunks: UpdateFileChunk[];
};
type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk;
export type ApplyPatchSummary = {
added: string[];
modified: string[];
deleted: string[];
};
type ApplyPatchResult = {
summary: ApplyPatchSummary;
text: string;
noOp?: boolean;
};
type ApplyPatchToolDetails = {
summary: ApplyPatchSummary;
};
function normalizeUpdateComparison(content: string): string {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (normalized.length === 0 || normalized.endsWith("\n")) {
return normalized;
}
return `${normalized}\n`;
}
type ApplyPatchOptions = ApplyPatchFileOptions & {
signal?: AbortSignal;
};
const applyPatchSchema = Type.Object({
input: Type.String({
description: "Patch content using the *** Begin Patch/End Patch format.",
}),
});
const ApplyPatchToolOutputSchema = Type.Object(
{
summary: Type.Object(
{
added: Type.Array(Type.String()),
modified: Type.Array(Type.String()),
deleted: Type.Array(Type.String()),
},
{ additionalProperties: false },
),
},
{ additionalProperties: false },
);
/** Create the agent tool wrapper for applying patch-envelope input. */
export function createApplyPatchTool(
options: {
cwd?: string;
sandbox?: SandboxApplyPatchConfig;
workspaceOnly?: boolean;
memoryWriteProvenance?: MemoryWriteProvenanceObserver;
} = {},
): AgentTool<typeof applyPatchSchema, ApplyPatchToolDetails> {
const cwd = options.cwd ?? process.cwd();
const sandbox = options.sandbox;
const workspaceOnly = options.workspaceOnly !== false;
return {
name: "apply_patch",
label: "apply_patch",
description: "Patch one/many files. Input requires *** Begin Patch and *** End Patch.",
parameters: applyPatchSchema,
outputSchema: ApplyPatchToolOutputSchema,
execute: async (_toolCallId, args, signal) => {
const params = args as { input?: string };
const input = typeof params.input === "string" ? params.input : "";
if (!input.trim()) {
throw new Error("Provide a patch input.");
}
if (signal?.aborted) {
throw createAbortError("Aborted");
}
const result = await applyPatch(input, {
cwd,
sandbox,
workspaceOnly,
memoryWriteProvenance: options.memoryWriteProvenance,
signal,
});
return {
content: [{ type: "text", text: result.text }],
details: { summary: result.summary },
...(result.noOp ? { terminate: true } : {}),
};
},
};
}
/** Parse and apply a patch envelope to the configured filesystem target. */
async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
const parsed = parsePatchText(input);
if (parsed.hunks.length === 0) {
throw new Error("No files were modified.");
}
const summary: ApplyPatchSummary = {
added: [],
modified: [],
deleted: [],
};
const seen = {
added: new Set<string>(),
modified: new Set<string>(),
deleted: new Set<string>(),
};
const noOpPaths = new Set<string>();
const fileOps = resolvePatchFileOps(options);
for (const hunk of parsed.hunks) {
if (options.signal?.aborted) {
throw createAbortError("Aborted");
}
if (hunk.kind === "add") {
const target = await resolvePatchPath(hunk.path, options);
await withFileMutationQueue(target.resolved, async () => {
await assertPatchParentPath(hunk.path, options);
await ensureDir(target.resolved, fileOps);
await createPatchTarget({
target,
contents: hunk.contents,
ops: fileOps,
hint: `Use "*** Update File: ${target.display}" to change it, or delete it earlier in the same patch.`,
});
});
recordSummary(summary, seen, "added", target.display);
continue;
}
if (hunk.kind === "delete") {
const target = await resolvePatchPath(hunk.path, options, PATH_ALIAS_POLICIES.unlinkTarget);
await withFileMutationQueue(target.resolved, () => fileOps.remove(target.resolved));
recordSummary(summary, seen, "deleted", target.display);
continue;
}
const target = await resolvePatchPath(hunk.path, options);
const moveTarget = hunk.movePath ? await resolvePatchPath(hunk.movePath, options) : undefined;
await withFileMutationQueues(
[target.resolved, ...(moveTarget ? [moveTarget.resolved] : [])],
async () => {
const applied = await applyUpdateHunk(target.resolved, hunk.chunks, {
readFile: (pathLocal) => fileOps.readFile(pathLocal),
});
if (hunk.movePath && moveTarget) {
await assertPatchParentPath(hunk.movePath, options);
await ensureDir(moveTarget.resolved, fileOps);
const moveResolvesToSource =
path.resolve(moveTarget.resolved) === path.resolve(target.resolved);
if (moveResolvesToSource) {
const existing = await fileOps.readFile(target.resolved);
if (normalizeUpdateComparison(existing) === normalizeUpdateComparison(applied)) {
noOpPaths.add(target.display);
} else {
noOpPaths.delete(target.display);
await fileOps.writeFile(target.resolved, applied);
}
} else {
noOpPaths.delete(target.display);
await createPatchTarget({
target: moveTarget,
contents: applied,
ops: fileOps,
hint: "Delete it earlier in the same patch to replace it.",
});
await fileOps.remove(target.resolved);
}
if (!noOpPaths.has(target.display)) {
recordSummary(
summary,
seen,
"modified",
moveResolvesToSource ? target.display : moveTarget.display,
);
}
return;
}
const existing = await fileOps.readFile(target.resolved);
if (normalizeUpdateComparison(existing) === normalizeUpdateComparison(applied)) {
noOpPaths.add(target.display);
} else {
noOpPaths.delete(target.display);
await fileOps.writeFile(target.resolved, applied);
recordSummary(summary, seen, "modified", target.display);
}
},
);
}
const noOp = noOpPaths.size > 0 && Object.values(summary).every((paths) => paths.length === 0);
return {
summary,
text: noOp ? `No changes made to ${Array.from(noOpPaths).join(", ")}.` : formatSummary(summary),
...(noOp ? { noOp: true } : {}),
};
}
function recordSummary(
summary: ApplyPatchSummary,
seen: {
added: Set<string>;
modified: Set<string>;
deleted: Set<string>;
},
bucket: keyof ApplyPatchSummary,
value: string,
) {
if (seen[bucket].has(value)) {
return;
}
seen[bucket].add(value);
summary[bucket].push(value);
}
function formatSummary(summary: ApplyPatchSummary): string {
const lines = ["Success. Updated the following files:"];
for (const file of summary.added) {
lines.push(`A ${file}`);
}
for (const file of summary.modified) {
lines.push(`M ${file}`);
}
for (const file of summary.deleted) {
lines.push(`D ${file}`);
}
return lines.join("\n");
}
async function ensureDir(filePath: string, ops: PatchFileOps) {
const parent = path.dirname(filePath);
if (!parent || parent === ".") {
return;
}
await ops.mkdirp(parent);
}
async function assertPatchParentPath(filePath: string, options: ApplyPatchOptions) {
if (options.workspaceOnly === false || options.sandbox) {
return;
}
const parent = path.dirname(filePath);
if (!parent || parent === ".") {
return;
}
await assertSandboxPath({
filePath: parent,
cwd: options.cwd,
root: options.cwd,
});
await assertNoExistingParentAliases({
parentPath: resolvePathFromInput(parent, options.cwd),
rootPath: options.cwd,
});
}
async function assertNoExistingParentAliases(params: { parentPath: string; rootPath: string }) {
const rootPath = path.resolve(params.rootPath);
const parentPath = path.resolve(params.parentPath);
const relative = path.relative(rootPath, parentPath);
if (!relative || relative === "" || relativePathEscapesRoot(relative)) {
return;
}
let current = rootPath;
for (const segment of relative.split(path.sep)) {
if (!segment) {
continue;
}
current = path.join(current, segment);
const stat = await fs.lstat(current).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
});
if (!stat) {
return;
}
if (stat.isSymbolicLink()) {
throw new Error(`Path alias under sandbox root: ${path.relative(rootPath, current)}`);
}
}
}
async function resolvePatchPath(
filePath: string,
options: ApplyPatchOptions,
aliasPolicy: PathAliasPolicy = PATH_ALIAS_POLICIES.strict,
): Promise<{ resolved: string; display: string }> {
if (options.sandbox) {
const resolved = options.sandbox.bridge.resolvePath({
filePath,
cwd: options.cwd,
});
if (options.workspaceOnly !== false && resolved.hostPath) {
await assertSandboxPath({
filePath: resolved.hostPath,
cwd: options.cwd,
root: options.cwd,
allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
});
}
return {
resolved: resolved.hostPath ?? resolved.containerPath,
display: resolved.relativePath || resolved.containerPath,
};
}
const workspaceOnly = options.workspaceOnly !== false;
const resolved = workspaceOnly
? (
await assertSandboxPath({
filePath,
cwd: options.cwd,
root: options.cwd,
allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
})
).resolved
: resolvePathFromInput(filePath, options.cwd);
return {
resolved,
display: toDisplayPath(resolved, options.cwd),
};
}
function toDisplayPath(resolved: string, cwd: string): string {
const relative = path.relative(cwd, resolved);
if (!relative || relative === "") {
return path.basename(resolved);
}
if (relativePathEscapesRoot(relative)) {
return resolved;
}
return relative;
}
function relativePathEscapesRoot(relativePath: string): boolean {
return (
relativePath === ".." ||
relativePath.startsWith("../") ||
relativePath.startsWith("..\\") ||
path.isAbsolute(relativePath)
);
}
function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Invalid patch: input is empty.");
}
const lines = trimmed.split(/\r?\n/);
const validated = checkPatchBoundariesLenient(lines);
const hunks: Hunk[] = [];
const lastLineIndex = validated.length - 1;
let remaining = validated.slice(1, lastLineIndex);
let lineNumber = 2;
while (remaining.length > 0) {
const { hunk, consumed } = parseOneHunk(remaining, lineNumber);
hunks.push(hunk);
lineNumber += consumed;
remaining = remaining.slice(consumed);
}
return { hunks, patch: validated.join("\n") };
}
function checkPatchBoundariesLenient(lines: string[]): string[] {
const strictError = checkPatchBoundariesStrict(lines);
if (!strictError) {
return lines;
}
if (lines.length < 4) {
throw new Error(strictError);
}
const first = lines[0];
const last = lines.at(-1);
if (
last &&
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
last.endsWith("EOF")
) {
const inner = lines.slice(1, -1);
const innerError = checkPatchBoundariesStrict(inner);
if (!innerError) {
return inner;
}
throw new Error(innerError);
}
throw new Error(strictError);
}
function checkPatchBoundariesStrict(lines: string[]): string | null {
const firstLine = lines[0]?.trim();
const lastLine = lines[lines.length - 1]?.trim();
if (firstLine === BEGIN_PATCH_MARKER && lastLine === END_PATCH_MARKER) {
return null;
}
if (firstLine !== BEGIN_PATCH_MARKER) {
return "The first line of the patch must be '*** Begin Patch'";
}
return "The last line of the patch must be '*** End Patch'";
}
function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
if (lines.length === 0) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
}
const firstLine = lines.at(0)?.trim();
if (firstLine === undefined) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
}
if (firstLine.startsWith(ADD_FILE_MARKER)) {
const targetPath = firstLine.slice(ADD_FILE_MARKER.length);
let contents = "";
let consumed = 1;
for (const addLine of lines.slice(1)) {
if (addLine.startsWith("+")) {
contents += `${addLine.slice(1)}\n`;
consumed += 1;
} else {
break;
}
}
return {
hunk: { kind: "add", path: targetPath, contents },
consumed,
};
}
if (firstLine.startsWith(DELETE_FILE_MARKER)) {
const targetPath = firstLine.slice(DELETE_FILE_MARKER.length);
return {
hunk: { kind: "delete", path: targetPath },
consumed: 1,
};
}
if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
const targetPath = firstLine.slice(UPDATE_FILE_MARKER.length);
let remaining = lines.slice(1);
let consumed = 1;
let movePath: string | undefined;
const moveCandidate = remaining[0]?.trim();
if (moveCandidate?.startsWith(MOVE_TO_MARKER)) {
movePath = moveCandidate.slice(MOVE_TO_MARKER.length);
remaining = remaining.slice(1);
consumed += 1;
}
const chunks: UpdateFileChunk[] = [];
while (remaining.length > 0) {
const firstRemaining = remaining.at(0);
if (firstRemaining === undefined) {
break;
}
if (firstRemaining.trim() === "") {
remaining = remaining.slice(1);
consumed += 1;
continue;
}
if (firstRemaining.startsWith("***")) {
break;
}
const { chunk, consumed: chunkLines } = parseUpdateFileChunk(
remaining,
lineNumber + consumed,
chunks.length === 0,
);
chunks.push(chunk);
remaining = remaining.slice(chunkLines);
consumed += chunkLines;
}
if (chunks.length === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Update file hunk for path '${targetPath}' is empty`,
);
}
return {
hunk: {
kind: "update",
path: targetPath,
movePath,
chunks,
},
consumed,
};
}
throw new Error(
`Invalid patch hunk at line ${lineNumber}: '${lines[0]}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
);
}
function parseUpdateFileChunk(
lines: string[],
lineNumber: number,
allowMissingContext: boolean,
): { chunk: UpdateFileChunk; consumed: number } {
if (lines.length === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Update hunk does not contain any lines`,
);
}
let changeContext: string | undefined;
let startIndex = 0;
const firstLine = lines.at(0);
if (firstLine === EMPTY_CHANGE_CONTEXT_MARKER) {
startIndex = 1;
} else if (firstLine?.startsWith(CHANGE_CONTEXT_MARKER)) {
changeContext = firstLine.slice(CHANGE_CONTEXT_MARKER.length);
startIndex = 1;
} else if (!allowMissingContext) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Expected update hunk to start with a @@ context marker, got: '${firstLine}'`,
);
}
if (startIndex >= lines.length) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
);
}
const chunk: UpdateFileChunk = {
changeContext,
oldLines: [],
newLines: [],
contextOldIndexes: [],
isEndOfFile: false,
};
let parsedLines = 0;
for (const line of lines.slice(startIndex)) {
if (line === EOF_MARKER) {
if (parsedLines === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
);
}
chunk.isEndOfFile = true;
parsedLines += 1;
break;
}
const marker = line[0];
if (!marker) {
chunk.contextOldIndexes.push(chunk.oldLines.length);
chunk.oldLines.push("");
chunk.newLines.push("");
parsedLines += 1;
continue;
}
if (marker === " ") {
const content = line.slice(1);
chunk.contextOldIndexes.push(chunk.oldLines.length);
chunk.oldLines.push(content);
chunk.newLines.push(content);
parsedLines += 1;
continue;
}
if (marker === "+") {
chunk.contextOldIndexes.push(undefined);
chunk.newLines.push(line.slice(1));
parsedLines += 1;
continue;
}
if (marker === "-") {
chunk.oldLines.push(line.slice(1));
parsedLines += 1;
continue;
}
if (parsedLines === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
);
}
break;
}
return { chunk, consumed: parsedLines + startIndex };
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.applyPatchTestApi")] = {
applyPatch,
};
}