Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/core/src/agent/tools/fs-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<typeof editArgs>, { 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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agent/tools/functions-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
143 changes: 143 additions & 0 deletions packages/core/src/functions-exec/apply-patch.ts
Original file line number Diff line number Diff line change
@@ -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<string, Snapshot>): Map<string, Buffer | undefined> {
const staged = new Map<string, Buffer | undefined>();
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<string, Snapshot>();
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"}`;
}
25 changes: 25 additions & 0 deletions packages/core/test/agent/engine-functions-exec.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/functions-exec/apply-patch.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});