class tools_io_basic : public tools_io {
public:
+ // cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
+ explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
+
bool is_directory(const std::string & path) const override {
std::error_code ec;
- return fs::is_directory(path, ec) && !ec;
+ return fs::is_directory(resolve(path), ec) && !ec;
}
bool is_regular_file(const std::string & path) const override {
std::error_code ec;
- return fs::is_regular_file(path, ec) && !ec;
+ return fs::is_regular_file(resolve(path), ec) && !ec;
}
bool file_size(const std::string & path, uintmax_t & out_size) const override {
std::error_code ec;
- out_size = fs::file_size(path, ec);
+ out_size = fs::file_size(resolve(path), ec);
return !ec;
}
bool read_file(const std::string & path, std::string & out) const override {
- std::ifstream f(path, std::ios::binary);
+ std::ifstream f(resolve(path), std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
bool write_file(const std::string & path, const std::string & content) const override {
std::error_code ec;
- fs::path fpath(path);
+ fs::path fpath(resolve(path));
if (fpath.has_parent_path()) {
fs::create_directories(fpath.parent_path(), ec);
if (ec) return false;
}
- std::ofstream f(path, std::ios::binary);
+ std::ofstream f(fpath, std::ios::binary);
if (!f) return false;
f << content;
return (bool) f;
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
err.clear();
+ std::string abs_base = resolve(base);
if (!is_directory(base)) {
err = "path does not exist or is not a directory: " + base;
return {};
}
auto res = run(
- {"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
+ {"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
return result;
}
- return list_files_fallback(base);
+ return list_files_fallback(abs_base);
}
exec_result run(
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
- if (!proc.create(args, options)) {
+ if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
}
private:
+ std::string cwd;
+
+ // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
+ std::string resolve(const std::string & path) const {
+ if (cwd.empty() || fs::path(path).is_absolute()) {
+ return path;
+ }
+ return (fs::path(cwd) / path).string();
+ }
+
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
};
static std::unique_ptr<tools_io> make_tools_io(const json & params) {
- GGML_UNUSED(params); // TODO in follow-up PR
- return std::make_unique<tools_io_basic>();
+ std::string cwd = json_value(params, "cwd", std::string());
+ return std::make_unique<tools_io_basic>(cwd);
}
// no '/' in pattern -> match basename at any depth; else match full relative path
return tools;
}
+static std::string str_to_lower(const std::string & value) {
+ std::string lowered(value.size(), '\0');
+ std::transform(value.begin(), value.end(), lowered.begin(), [](unsigned char c) { return std::tolower(c); });
+ return lowered;
+}
+
+static std::string get_header(const std::map<std::string, std::string> & headers, const std::string & key, std::string default_value = "") {
+ const auto lowered_key = str_to_lower(key);
+ for (const auto & h : headers) {
+ if (str_to_lower(h.first) == lowered_key) {
+ return h.second;
+ }
+ }
+ return default_value;
+}
+
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
if (!enabled_tools.empty()) {
json params = body.value("params", json::object());
bool stream = body.value("stream", false);
+ // accept x-tool-cwd header to override of the process
+ auto cwd = get_header(req.headers, "x-tool-cwd");
+ if (!cwd.empty()) {
+ params["cwd"] = cwd;
+ }
+
server_tool & tool = find_tool(tools, tool_name, stream);
if (stream) {
server.server_tools = "all"
-def call_tool(name: str, params: dict) -> dict:
- res = server.make_request("POST", "/tools", data={"tool": name, "params": params})
+def call_tool(name: str, params: dict, headers: dict | None = None) -> dict:
+ res = server.make_request("POST", "/tools", data={"tool": name, "params": params}, headers=headers)
assert res.status_code == 200, res.body
assert "error" not in res.body, res.body
return res.body
assert "[exit code: 0]" in chunks
+def test_tools_builtin_cwd_header():
+ global server
+ server.start()
+
+ cwd_dir = os.path.join(PROJECT_ROOT, "tools", "server", "tests", "unit")
+ headers = {"x-tool-cwd": cwd_dir}
+
+ res = call_tool("read_file", {"path": "test_tools_builtin.py"}, headers=headers)
+ assert GREP_MARKER in res["plain_text_response"]
+
+ # exec_shell_command should also run with that directory as its working directory:
+ # writing to a relative filename must land inside cwd_dir
+ marker_name = "llama_cpp_test_tools_builtin_cwd_marker.txt"
+ marker_path = os.path.join(cwd_dir, marker_name)
+ try:
+ command = f"echo hello > {marker_name}"
+ call_tool("exec_shell_command", {"command": command}, headers=headers)
+ assert os.path.exists(marker_path)
+ finally:
+ if os.path.exists(marker_path):
+ os.remove(marker_path)
+
+
def test_tools_builtin_edit_file_rejects_overlapping_edits():
global server
server.start()