diff --git a/packages/core/src/agent/tools/fs-write.ts b/packages/core/src/agent/tools/fs-write.ts index ad003b32..ad925fbf 100644 --- a/packages/core/src/agent/tools/fs-write.ts +++ b/packages/core/src/agent/tools/fs-write.ts @@ -4,6 +4,12 @@ import { dirname } from "node:path"; import { resolveWithinAny } from "../paths"; import type { ToolRegistry } from "./registry"; import { withFileDiff } from "./diff-report"; +import { applyCodexPatch } from "../../functions-exec/apply-patch"; + +const editArgs = z.union([ + z.object({ path: z.string().min(1), old_string: z.string().min(1), new_string: z.string(), replace_all: z.boolean().optional() }), + z.object({ patch: z.string().min(1) }).strict(), +]); export function registerWriteTools(r: ToolRegistry): void { r.register({ @@ -34,10 +40,12 @@ export function registerWriteTools(r: ToolRegistry): void { r.register({ name: "edit", - description: "Replace an exact string in a file. old_string must match exactly (including whitespace) and, unless replace_all is true, be UNIQUE in the file — the edit fails otherwise. replace_all: true replaces every occurrence.", - args: z.object({ path: z.string().min(1), old_string: z.string().min(1), new_string: z.string(), replace_all: z.boolean().optional() }), + description: "Edit files either by replacing an exact string (old_string must match exactly and be unique unless replace_all is true) or by supplying a raw Codex apply_patch patch string in patch.", + args: editArgs, // Out-of-root targets: same grant-before-dispatch story as `write` above. - async run({ path, old_string, new_string, replace_all }, { roots, diffSink }) { + async run(args: z.infer, { roots, diffSink }) { + if ("patch" in args) return applyCodexPatch(args.patch, roots); + const { path, old_string, new_string, replace_all } = args; const target = resolveWithinAny(roots, path); const text = readFileSync(target, "utf8"); const count = text.split(old_string).length - 1; diff --git a/packages/core/src/agent/tools/functions-exec.ts b/packages/core/src/agent/tools/functions-exec.ts index cf87197b..1e634c50 100644 --- a/packages/core/src/agent/tools/functions-exec.ts +++ b/packages/core/src/agent/tools/functions-exec.ts @@ -23,7 +23,7 @@ export function registerFunctionsExecTools(registry: ToolRegistry, supported = f if (!supported) return; registry.register({ name: FUNCTIONS_EXEC_TOOL, - description: "Run bounded JavaScript in an isolated worker. Load this deferred tool with ToolSearch first. JavaScript has no direct filesystem, process, or network access; use tools.bash, tools.read, tools.web_fetch, or tools.web_search, which each use Norma's normal permission path. Use tools.text(), tools.image(), tools.audio(), tools.notify(), and await tools.yield().", + description: "Run bounded JavaScript in an isolated worker. Load this deferred tool with ToolSearch first. JavaScript has no direct filesystem, process, or network access; use tools.bash, tools.edit, tools.read, tools.web_fetch, or tools.web_search, which each use Norma's normal permission path. Use tools.text(), tools.image(), tools.audio(), tools.notify(), and await tools.yield().", args: functionsExecArgs, modes: ["code"], deferred: true, diff --git a/packages/core/src/functions-exec/apply-patch.ts b/packages/core/src/functions-exec/apply-patch.ts new file mode 100644 index 00000000..321b513d --- /dev/null +++ b/packages/core/src/functions-exec/apply-patch.ts @@ -0,0 +1,143 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolveWithinAny } from "../agent/paths"; +import { parseCodexPatch, type PatchHunk, type PatchOperation } from "./patch"; + +interface Snapshot { + requestedPath: string; + path: string; + bytes?: Buffer; +} + +function splitFile(content: string): { lines: string[]; newline: "\n" | "\r\n"; trailingNewline: boolean } { + const firstLineFeed = content.indexOf("\n"); + const newline = firstLineFeed > 0 && content[firstLineFeed - 1] === "\r" ? "\r\n" : "\n"; + const trailingNewline = content.endsWith(newline); + const body = trailingNewline ? content.slice(0, -newline.length) : content; + return { lines: body.length === 0 ? [] : body.split(newline), newline, trailingNewline }; +} + +function joinFile(lines: string[], newline: "\n" | "\r\n", trailingNewline: boolean): string { + return lines.length === 0 ? "" : `${lines.join(newline)}${trailingNewline ? newline : ""}`; +} + +function matchingLineIndices(lines: string[], expected: string[], start: number, endOfFile: boolean): number[] { + const matches: number[] = []; + for (let index = start; index + expected.length <= lines.length; index += 1) { + if (endOfFile && index + expected.length !== lines.length) continue; + if (expected.every((line, offset) => lines[index + offset] === line)) matches.push(index); + } + return matches; +} + +function uniqueLineIndex(lines: string[], expected: string, start: number, path: string): number { + const matches = lines.flatMap((line, index) => index >= start && line === expected ? [index] : []); + if (matches.length === 0) throw new Error(`failed to find context '${expected}' in ${path}`); + if (matches.length > 1) throw new Error(`context '${expected}' matches more than once in ${path}`); + return matches[0]!; +} + +function applyUpdate(content: string, path: string, hunks: PatchHunk[]): string { + const parsed = splitFile(content); + let cursor = 0; + for (const hunk of hunks) { + let start = cursor; + if (hunk.context !== undefined) start = uniqueLineIndex(parsed.lines, hunk.context, cursor, path) + 1; + let index: number; + if (hunk.oldLines.length === 0) { + index = hunk.context === undefined || hunk.endOfFile ? parsed.lines.length : start; + } else { + const matches = matchingLineIndices(parsed.lines, hunk.oldLines, start, hunk.endOfFile); + if (matches.length === 0) throw new Error(`failed to find expected lines in ${path}`); + if (matches.length > 1) throw new Error(`expected lines are ambiguous in ${path}`); + index = matches[0]!; + } + parsed.lines.splice(index, hunk.oldLines.length, ...hunk.newLines); + cursor = index + hunk.newLines.length; + } + return joinFile(parsed.lines, parsed.newline, parsed.trailingNewline); +} + +function decode(bytes: Buffer, path: string): string { + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { throw new Error(`patch target is not valid UTF-8: ${path}`); } +} + +function pathsInOrder(operations: PatchOperation[]): string[] { + return [...new Set(operations.map((operation) => operation.path))]; +} + +function stage(operations: PatchOperation[], snapshots: Map): Map { + const staged = new Map(); + for (const [path, snapshot] of snapshots) staged.set(path, snapshot.bytes); + for (const operation of operations) { + const before = staged.get(operation.path); + switch (operation.type) { + case "add": + if (before !== undefined) throw new Error(`add target already exists: ${operation.path}`); + staged.set(operation.path, Buffer.from(operation.content)); + break; + case "update": + if (before === undefined) throw new Error(`update target does not exist: ${operation.path}`); + staged.set(operation.path, Buffer.from(applyUpdate(decode(before, operation.path), operation.path, operation.hunks))); + break; + case "delete": + if (before === undefined) throw new Error(`delete target does not exist: ${operation.path}`); + staged.set(operation.path, undefined); + break; + } + } + return staged; +} + +function restore(snapshot: Snapshot): void { + if (snapshot.bytes === undefined) { + if (existsSync(snapshot.path)) unlinkSync(snapshot.path); + return; + } + mkdirSync(dirname(snapshot.path), { recursive: true }); + writeFileSync(snapshot.path, snapshot.bytes); +} + +/** + * Applies the validated Codex patch grammar after every target has been resolved, snapshotted, and + * staged. No directory or file is touched during preflight; a failed mutation restores the captured + * targets in reverse order. Parent paths and all leaf targets remain constrained to `roots`. + */ +export function applyCodexPatch(patch: string, roots: string[]): string { + const operations = parseCodexPatch(patch); + const snapshots = new Map(); + for (const requestedPath of pathsInOrder(operations)) { + const path = resolveWithinAny(roots, requestedPath); + if (existsSync(path)) { + const stats = lstatSync(path); + if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`patch target must be a regular file: ${requestedPath}`); + snapshots.set(requestedPath, { requestedPath, path, bytes: readFileSync(path) }); + } else { + snapshots.set(requestedPath, { requestedPath, path }); + } + } + const staged = stage(operations, snapshots); + const changed: Snapshot[] = []; + try { + for (const [requestedPath, snapshot] of snapshots) { + const next = staged.get(requestedPath); + changed.push(snapshot); + if (next === undefined) { + if (snapshot.bytes !== undefined) unlinkSync(snapshot.path); + } else { + mkdirSync(dirname(snapshot.path), { recursive: true }); + writeFileSync(snapshot.path, next); + } + } + } catch (error) { + const failures: string[] = []; + for (const snapshot of changed.reverse()) { + try { restore(snapshot); } + catch (restoreError) { failures.push(`${snapshot.requestedPath}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`); } + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error(failures.length === 0 ? `${detail}; patch transaction rolled back` : `${detail}; rollback failed: ${failures.join("; ")}`); + } + return `Applied patch to ${snapshots.size} file${snapshots.size === 1 ? "" : "s"}`; +} diff --git a/packages/core/test/agent/engine-functions-exec.test.ts b/packages/core/test/agent/engine-functions-exec.test.ts index 2ba6cf0a..681c2e6e 100644 --- a/packages/core/test/agent/engine-functions-exec.test.ts +++ b/packages/core/test/agent/engine-functions-exec.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { z } from "zod"; import { ToolRegistry } from "../../src/agent/tools/registry"; import { registerToolSearchTool } from "../../src/agent/tools/toolsearch"; @@ -106,6 +108,29 @@ describe("engine functions.exec integration", () => { expect(events.some((event) => event.type === "approval_requested")).toBe(false); }); + test("runs a nested raw patch through edit without an approval card in accept-edits mode", async () => { + const registry = new ToolRegistry(); + registerToolSearchTool(registry); + registerFunctionsExecTools(registry, true); + const patch = "*** Begin Patch\n*** Add File: nested.txt\n+created through tools.edit\n*** End Patch"; + const provider = new FakeProvider([ + [{ type: "tool_call", callId: "load", name: "ToolSearch", argsJson: JSON.stringify({ query: "select:functions.exec" }) }, done("tool_calls")], + [{ type: "tool_call", callId: "run", name: "functions.exec", argsJson: JSON.stringify({ source: "ignored" }) }, done("tool_calls")], + [{ type: "text_delta", delta: "done" }, done("end_turn")], + ]); + const { engine, events, sessionId, cwd } = setupEngine(provider, { + registry, + policy: "accept-edits", + toolSearch: {}, + functionsExecRuntimeFactory: runtimeForNestedCall({ name: "edit", args: { patch } }), + }); + + await engine.runTurn(sessionId); + + expect(readFileSync(join(cwd, "nested.txt"), "utf8")).toBe("created through tools.edit\n"); + expect(events.some((event) => event.type === "approval_requested")).toBe(false); + }); + test("interrupt cancels a yielded cell after its outer turn has already settled", async () => { const registry = new ToolRegistry(); registerToolSearchTool(registry); diff --git a/packages/core/test/functions-exec/apply-patch.test.ts b/packages/core/test/functions-exec/apply-patch.test.ts new file mode 100644 index 00000000..0ea2be02 --- /dev/null +++ b/packages/core/test/functions-exec/apply-patch.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { applyCodexPatch } from "../../src/functions-exec/apply-patch"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +function workspace(): string { + const dir = mkdtempSync(join(tmpdir(), "norma-functions-patch-")); + dirs.push(dir); + return dir; +} + +describe("functions-exec raw patch application", () => { + test("stages add, update, and delete before committing the full Codex patch", () => { + const root = workspace(); + writeFileSync(join(root, "update.txt"), "old\n"); + writeFileSync(join(root, "delete.txt"), "delete me\n"); + const patch = "*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch"; + expect(applyCodexPatch(patch, [root])).toBe("Applied patch to 3 files"); + expect(readFileSync(join(root, "add.txt"), "utf8")).toBe("added\n"); + expect(readFileSync(join(root, "update.txt"), "utf8")).toBe("new\n"); + expect(() => readFileSync(join(root, "delete.txt"))).toThrow(); + }); + + test("does not mutate any target when patch preflight cannot stage every operation", () => { + const root = workspace(); + writeFileSync(join(root, "one.txt"), "one\n"); + const patch = "*** Begin Patch\n*** Update File: one.txt\n@@\n-one\n+two\n*** Update File: missing.txt\n@@\n-missing\n+present\n*** End Patch"; + expect(() => applyCodexPatch(patch, [root])).toThrow("update target does not exist"); + expect(readFileSync(join(root, "one.txt"), "utf8")).toBe("one\n"); + }); +});