Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -224,32 +224,43 @@ public EditResult edit(
Base64.getEncoder()
.encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));

// Use real newlines inside the python3 -c program. Java "\\n" would emit a
// literal backslash-n, which POSIX shells pass through and Python rejects
// as a SyntaxError (see #2571).
String cmd =
"python3 -c \"import sys, os, base64, json\\n"
"python3 -c \"import sys, os, base64, json\n"
+ "payload ="
+ " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\\n"
+ "path, old, new = payload['path'], payload['old'], payload['new']\\n"
+ "replace_all = payload.get('replace_all', False)\\n"
+ "if not os.path.isfile(path):\\n"
+ " print(json.dumps({'error': 'file_not_found'}))\\n"
+ " sys.exit(0)\\n"
+ "with open(path, 'rb') as f: text = f.read().decode('utf-8')\\n"
+ "count = text.count(old)\\n"
+ "if count == 0:\\n"
+ " print(json.dumps({'error': 'string_not_found'}))\\n"
+ " sys.exit(0)\\n"
+ "if count > 1 and not replace_all:\\n"
+ " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\\n"
+ " sys.exit(0)\\n"
+ " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\n"
+ "path, old, new = payload['path'], payload['old'], payload['new']\n"
+ "replace_all = payload.get('replace_all', False)\n"
+ "if not os.path.isfile(path):\n"
+ " print(json.dumps({'error': 'file_not_found'}))\n"
+ " sys.exit(0)\n"
+ "with open(path, 'rb') as f: text = f.read().decode('utf-8')\n"
+ "count = text.count(old)\n"
+ "if count == 0:\n"
+ " print(json.dumps({'error': 'string_not_found'}))\n"
+ " sys.exit(0)\n"
+ "if count > 1 and not replace_all:\n"
+ " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\n"
+ " sys.exit(0)\n"
+ "result = text.replace(old, new) if replace_all else text.replace(old, new,"
+ " 1)\\n"
+ "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\\n"
+ "print(json.dumps({'count': count}))\\n"
+ " 1)\n"
+ "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\n"
+ "print(json.dumps({'count': count}))\n"
+ "\" 2>&1 <<'__EDIT_EOF__'\n"
+ payloadB64
+ "\n__EDIT_EOF__\n";

ExecuteResponse result = execute(runtimeContext, cmd, null);
if (result.exitCode() != null && result.exitCode() != 0) {
String err = result.output() != null ? result.output().strip() : "";
return EditResult.fail(
"Error editing file '"
+ filePath
+ "': "
+ err.substring(0, Math.min(200, err.length())));
}
String output = result.output() != null ? result.output().strip() : "";

if (output.contains("\"error\"")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,72 @@ void ls_reportsDirModifiedAt() {
assertTrue(dir.isDirectory());
assertFalse(dir.modifiedAt().isEmpty(), "dir modifiedAt should be populated");
}

@Test
void edit_pythonCommand_usesRealNewlinesNotLiteralBackslashN() {
FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem();
filesystem.nextEditResponse = new ExecuteResponse("{\"count\": 1}", 0, false);

var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false);

assertTrue(result.isSuccess());
// Real LF between "json" and "payload" — not the two-char sequence '\' + 'n'.
assertTrue(
filesystem.lastCommand.contains("json\npayload"),
"python -c program must contain real newlines");
assertFalse(
filesystem.lastCommand.contains("json" + "\\" + "npayload"),
"must not emit literal backslash-n (POSIX shell / Python SyntaxError)");
}

@Test
void edit_nonzeroExitCode_returnsFailureWithOutput() {
FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem();
filesystem.nextEditResponse =
new ExecuteResponse("SyntaxError: unexpected character", 1, false);

var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false);

assertFalse(result.isSuccess());
assertTrue(result.error().contains("SyntaxError"));
}

@Test
void edit_nonzeroExitCode_nullOutput_returnsFailure() {
FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem();
// Covers: exitCode != 0 && output == null -> err = ""
filesystem.nextEditResponse = new ExecuteResponse(null, 1, false);

var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false);

assertFalse(result.isSuccess());
assertTrue(result.error().contains("Error editing file"));
}

@Test
void edit_nullExitCode_withCount_stillSucceeds() {
FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem();
// Covers: exitCode == null -> skip nonzero-fail branch
filesystem.nextEditResponse = new ExecuteResponse("{\"count\": 2}", null, false);

var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false);

assertTrue(result.isSuccess());
assertEquals(2, result.occurrences());
}

@Test
void edit_nonzeroExitCode_longOutput_isTruncatedTo200() {
FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem();
String longErr = "E".repeat(250);
filesystem.nextEditResponse = new ExecuteResponse(longErr, 2, false);

var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false);

assertFalse(result.isSuccess());
assertTrue(result.error().endsWith("E".repeat(200)));
assertFalse(result.error().contains("E".repeat(201)));
}
}

// ================================================================
Expand Down Expand Up @@ -215,6 +281,7 @@ void glob_emptyResultWhenNoMatch() {
private static final class FakeSandboxFilesystem extends BaseSandboxFilesystem {

String lastCommand;
ExecuteResponse nextEditResponse;

@Override
public String id() {
Expand All @@ -225,6 +292,9 @@ public String id() {
public ExecuteResponse execute(
RuntimeContext runtimeContext, String command, Integer timeoutSeconds) {
lastCommand = command;
if (command.startsWith("python3 -c") && nextEditResponse != null) {
return nextEditResponse;
}
if (command.startsWith("for f in ") && command.contains("stat -c")) {
return new ExecuteResponse(
"DIR:/workspace/docs\t1719300000\n"
Expand Down
Loading