mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix: resolve workspace context for file mutations (#617)
This commit is contained in:
parent
8d32a60fdb
commit
14df331ce6
@ -3,14 +3,18 @@ package vip.mate.tool.builtin;
|
|||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.tool.ConcurrencyUnsafe;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置工具:编辑文件(查找替换)
|
* 内置工具:编辑文件(查找替换)
|
||||||
@ -31,9 +35,9 @@ import java.nio.file.Paths;
|
|||||||
@lombok.RequiredArgsConstructor
|
@lombok.RequiredArgsConstructor
|
||||||
public class EditFileTool {
|
public class EditFileTool {
|
||||||
|
|
||||||
private final vip.mate.i18n.I18nService i18n;
|
private final I18nService i18n;
|
||||||
|
|
||||||
@vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path")
|
@ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path")
|
||||||
@Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. "
|
@Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. "
|
||||||
+ "Returns structured JSON with filePath, replacements count. "
|
+ "Returns structured JSON with filePath, replacements count. "
|
||||||
+ "May require user approval when security rules flag the edit. "
|
+ "May require user approval when security rules flag the edit. "
|
||||||
@ -42,7 +46,8 @@ public class EditFileTool {
|
|||||||
@ToolParam(description = "Absolute or relative file path") String filePath,
|
@ToolParam(description = "Absolute or relative file path") String filePath,
|
||||||
@ToolParam(description = "Original text to find (exact match)") String oldText,
|
@ToolParam(description = "Original text to find (exact match)") String oldText,
|
||||||
@ToolParam(description = "Replacement text") String newText,
|
@ToolParam(description = "Replacement text") String newText,
|
||||||
@ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll) {
|
@ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("filePath", filePath);
|
result.set("filePath", filePath);
|
||||||
@ -63,7 +68,7 @@ public class EditFileTool {
|
|||||||
|
|
||||||
Path path;
|
Path path;
|
||||||
try {
|
try {
|
||||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
|
path = WorkspacePathGuard.validatePath(filePath, ctx);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return errorResult(filePath, e.getMessage());
|
return errorResult(filePath, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,18 @@ package vip.mate.tool.builtin;
|
|||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.tool.ConcurrencyUnsafe;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置工具:写入文件
|
* 内置工具:写入文件
|
||||||
@ -31,15 +35,16 @@ import java.nio.file.Paths;
|
|||||||
@lombok.RequiredArgsConstructor
|
@lombok.RequiredArgsConstructor
|
||||||
public class WriteFileTool {
|
public class WriteFileTool {
|
||||||
|
|
||||||
private final vip.mate.i18n.I18nService i18n;
|
private final I18nService i18n;
|
||||||
|
|
||||||
@vip.mate.tool.ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths")
|
@ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths")
|
||||||
@Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). "
|
@Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). "
|
||||||
+ "Returns structured JSON with filePath, bytesWritten. "
|
+ "Returns structured JSON with filePath, bytesWritten. "
|
||||||
+ "May require user approval when security rules flag the write.")
|
+ "May require user approval when security rules flag the write.")
|
||||||
public String write_file(
|
public String write_file(
|
||||||
@ToolParam(description = "Absolute or relative file path") String filePath,
|
@ToolParam(description = "Absolute or relative file path") String filePath,
|
||||||
@ToolParam(description = "Content to write to the file") String content) {
|
@ToolParam(description = "Content to write to the file") String content,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("filePath", filePath);
|
result.set("filePath", filePath);
|
||||||
@ -54,7 +59,7 @@ public class WriteFileTool {
|
|||||||
|
|
||||||
Path path;
|
Path path;
|
||||||
try {
|
try {
|
||||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
|
path = WorkspacePathGuard.validatePath(filePath, ctx);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return errorResult(filePath, e.getMessage());
|
return errorResult(filePath, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for issue #617: file mutation tools must use the
|
||||||
|
* explicit Spring AI ToolContext instead of relying on legacy thread-local
|
||||||
|
* workspace state.
|
||||||
|
*/
|
||||||
|
class FileMutationToolContextTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
WorkspacePathGuard.setDefaultRoot(null);
|
||||||
|
ToolExecutionContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("write_file resolves relative paths against ToolContext workspace root (#617)")
|
||||||
|
void writeFileUsesToolContextWorkspaceRoot(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root"));
|
||||||
|
Path contextRoot = Files.createDirectory(tempDir.resolve("context-root"));
|
||||||
|
WorkspacePathGuard.setDefaultRoot(defaultRoot.toString());
|
||||||
|
WriteFileTool tool = new WriteFileTool(i18n());
|
||||||
|
|
||||||
|
String result = tool.write_file(
|
||||||
|
"deck.md",
|
||||||
|
"# Deck",
|
||||||
|
ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext());
|
||||||
|
|
||||||
|
assertThat(JSONUtil.parseObj(result).getBool("error", false)).isFalse();
|
||||||
|
assertThat(contextRoot.resolve("deck.md")).hasContent("# Deck");
|
||||||
|
assertThat(defaultRoot.resolve("deck.md")).doesNotExist();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("edit_file resolves relative paths against ToolContext workspace root (#617)")
|
||||||
|
void editFileUsesToolContextWorkspaceRoot(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root"));
|
||||||
|
Path contextRoot = Files.createDirectory(tempDir.resolve("context-root"));
|
||||||
|
WorkspacePathGuard.setDefaultRoot(defaultRoot.toString());
|
||||||
|
Files.writeString(contextRoot.resolve("deck.md"), "old title", StandardCharsets.UTF_8);
|
||||||
|
EditFileTool tool = new EditFileTool(i18n());
|
||||||
|
|
||||||
|
String result = tool.edit_file(
|
||||||
|
"deck.md",
|
||||||
|
"old",
|
||||||
|
"new",
|
||||||
|
false,
|
||||||
|
ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext());
|
||||||
|
|
||||||
|
assertThat(JSONUtil.parseObj(result).getBool("error", false)).isFalse();
|
||||||
|
assertThat(contextRoot.resolve("deck.md")).hasContent("new title");
|
||||||
|
assertThat(defaultRoot.resolve("deck.md")).doesNotExist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static I18nService i18n() {
|
||||||
|
return mock(I18nService.class, inv ->
|
||||||
|
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user