diff --git a/assets/functions/scripts/run-agent.py b/assets/functions/scripts/run-agent.py index 206e0c5..15ce9b1 100755 --- a/assets/functions/scripts/run-agent.py +++ b/assets/functions/scripts/run-agent.py @@ -27,14 +27,30 @@ def _ensure_cwd_venv(): _ensure_cwd_venv() +def resolve_dir(env_name, default_path): + """Resolve a directory at run time. + + Prefer the override env var when set, otherwise fall back to the default + path derived from this script's own location, so the shim keeps working + when the config dir moves or is shared across environments with different + home directories. + """ + value = os.environ.get(env_name) + if value: + return value + return os.path.normpath(default_path) + + def main(): (agent_func, raw_data) = parse_argv() agent_data = parse_raw_data(raw_data) - root_dir = "{config_dir}" - setup_env(root_dir, agent_func, raw_data) + self_dir = os.path.dirname(os.path.abspath(__file__)) + agent_dir = os.path.normpath(os.path.join(self_dir, "..")) + root_dir = resolve_dir("{root_dir_env}", os.path.join(self_dir, "{root_dir_rel}")) + setup_env(root_dir, agent_dir, agent_func, raw_data) - agent_tools_path = os.path.join(root_dir, "agents/{agent_name}/tools.py") + agent_tools_path = os.path.join(agent_dir, "tools.py") run(agent_tools_path, agent_func, agent_data) @@ -65,12 +81,12 @@ def parse_argv(): return agent_func, agent_data -def setup_env(root_dir, agent_func, raw_data): +def setup_env(root_dir, agent_dir, agent_func, raw_data): load_env(os.path.join(root_dir, ".env")) os.environ["LLM_ROOT_DIR"] = root_dir os.environ["LLM_AGENT_NAME"] = "{agent_name}" os.environ["LLM_AGENT_FUNC"] = agent_func - os.environ["LLM_AGENT_ROOT_DIR"] = os.path.join(root_dir, "agents", "{agent_name}") + os.environ["LLM_AGENT_ROOT_DIR"] = agent_dir os.environ["LLM_AGENT_CACHE_DIR"] = os.path.join(root_dir, "cache", "{agent_name}") os.environ["LLM_AGENT_RAW_JSON"] = raw_data diff --git a/assets/functions/scripts/run-agent.sh b/assets/functions/scripts/run-agent.sh index d72b955..cf7e4dd 100644 --- a/assets/functions/scripts/run-agent.sh +++ b/assets/functions/scripts/run-agent.sh @@ -5,13 +5,30 @@ set -e main() { - root_dir="{config_dir}" + self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + agent_dir="$(cd "$self_dir/.." && pwd)" + root_dir="$(resolve_dir "{root_dir_env}" "$self_dir/{root_dir_rel}")" + functions_dir="$(resolve_dir "{functions_dir_env}" "$self_dir/{functions_dir_rel}")" parse_argv "$@" setup_env - tools_path="$root_dir/agents/{agent_name}/tools.sh" + tools_path="$agent_dir/tools.sh" run } +# Resolve a directory at run time: prefer the override env var ($1) when set, +# otherwise fall back to the default path ($2) derived from this script's own +# location, so the shim keeps working when the config dir moves or is shared +# across environments with different home directories. +resolve_dir() { + local override + override="$(printenv "$1" 2>/dev/null || true)" + if [[ -n "$override" ]]; then + echo "$override" + else + (cd "$2" 2>/dev/null && pwd) || echo "$2" + fi +} + parse_argv() { agent_func="$1" if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then @@ -29,9 +46,9 @@ setup_env() { export LLM_ROOT_DIR="$root_dir" export LLM_AGENT_NAME="{agent_name}" export LLM_AGENT_FUNC="$agent_func" - export LLM_AGENT_ROOT_DIR="$LLM_ROOT_DIR/agents/{agent_name}" + export LLM_AGENT_ROOT_DIR="$agent_dir" export LLM_AGENT_CACHE_DIR="$LLM_ROOT_DIR/cache/{agent_name}" - export LLM_PROMPT_UTILS_FILE="{prompt_utils_file}" + export LLM_PROMPT_UTILS_FILE="$functions_dir/utils/prompt-utils.sh" export LLM_AGENT_RAW_JSON="$agent_data" } @@ -59,6 +76,10 @@ run() { die "error: no JSON data" fi + if [[ ! -f "$tools_path" ]]; then + die "error: agent tools script not found: $tools_path" + fi + if [[ "$OS" == "Windows_NT" ]]; then set -o igncr tools_path="$(cygpath -w "$tools_path")" @@ -122,4 +143,3 @@ die() { } main "$@" - diff --git a/assets/functions/scripts/run-agent.ts b/assets/functions/scripts/run-agent.ts index 0195383..06b9a33 100644 --- a/assets/functions/scripts/run-agent.ts +++ b/assets/functions/scripts/run-agent.ts @@ -3,17 +3,38 @@ // Usage: ./{agent_name}.ts import { readFileSync, writeFileSync, existsSync } from "fs"; -import { join } from "path"; -import { pathToFileURL } from "url"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; + +function selfDir(): string { + if (typeof __dirname !== "undefined") { + return __dirname; + } + return dirname(fileURLToPath(import.meta.url)); +} + +// Resolve a directory at run time: prefer the override env var when set, +// otherwise fall back to the default path derived from this script's own +// location, so the shim keeps working when the config dir moves or is shared +// across environments with different home directories. +function resolveDir(envName: string, defaultPath: string): string { + const value = process.env[envName]; + if (value) { + return value; + } + return resolve(defaultPath); +} async function main(): Promise { const { agentFunc, rawData } = parseArgv(); const agentData = parseRawData(rawData); - const configDir = "{config_dir}"; - setupEnv(configDir, agentFunc, rawData); + const binDir = selfDir(); + const agentDir = resolve(binDir, ".."); + const configDir = resolveDir("{root_dir_env}", join(binDir, "{root_dir_rel}")); + setupEnv(configDir, agentDir, agentFunc, rawData); - const agentToolsPath = join(configDir, "agents", "{agent_name}", "tools.ts"); + const agentToolsPath = join(agentDir, "tools.ts"); await run(agentToolsPath, agentFunc, agentData); } @@ -48,12 +69,17 @@ function parseArgv(): { agentFunc: string; rawData: string } { return { agentFunc, rawData: agentData }; } -function setupEnv(configDir: string, agentFunc: string, rawData: string): void { +function setupEnv( + configDir: string, + agentDir: string, + agentFunc: string, + rawData: string, +): void { loadEnv(join(configDir, ".env")); process.env["LLM_ROOT_DIR"] = configDir; process.env["LLM_AGENT_NAME"] = "{agent_name}"; process.env["LLM_AGENT_FUNC"] = agentFunc; - process.env["LLM_AGENT_ROOT_DIR"] = join(configDir, "agents", "{agent_name}"); + process.env["LLM_AGENT_ROOT_DIR"] = agentDir; process.env["LLM_AGENT_CACHE_DIR"] = join(configDir, "cache", "{agent_name}"); process.env["LLM_AGENT_RAW_JSON"] = rawData; } diff --git a/assets/functions/scripts/run-tool.py b/assets/functions/scripts/run-tool.py index b2d2b7a..54ec410 100644 --- a/assets/functions/scripts/run-tool.py +++ b/assets/functions/scripts/run-tool.py @@ -27,14 +27,30 @@ def _ensure_cwd_venv(): _ensure_cwd_venv() +def resolve_dir(env_name, default_path): + """Resolve a directory at run time. + + Prefer the override env var when set, otherwise fall back to the default + path derived from this script's own location, so the shim keeps working + when the config dir moves or is shared across environments with different + home directories. + """ + value = os.environ.get(env_name) + if value: + return value + return os.path.normpath(default_path) + + def main(): raw_data = parse_argv() tool_data = parse_raw_data(raw_data) - root_dir = "{root_dir}" + self_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = resolve_dir("{root_dir_env}", os.path.join(self_dir, "{root_dir_rel}")) + functions_dir = resolve_dir("{functions_dir_env}", os.path.join(self_dir, "{functions_dir_rel}")) setup_env(root_dir, raw_data) - tool_path = "{tool_path}.py" + tool_path = os.path.join(functions_dir, "tools", "{function_name}.py") run(tool_path, "run", tool_data) diff --git a/assets/functions/scripts/run-tool.sh b/assets/functions/scripts/run-tool.sh index df07249..b5d5301 100644 --- a/assets/functions/scripts/run-tool.sh +++ b/assets/functions/scripts/run-tool.sh @@ -5,13 +5,29 @@ set -e main() { - root_dir="{root_dir}" + self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + root_dir="$(resolve_dir "{root_dir_env}" "$self_dir/{root_dir_rel}")" + functions_dir="$(resolve_dir "{functions_dir_env}" "$self_dir/{functions_dir_rel}")" parse_argv "$@" setup_env - tool_path="{tool_path}.sh" + tool_path="$functions_dir/tools/{function_name}.sh" run } +# Resolve a directory at run time: prefer the override env var ($1) when set, +# otherwise fall back to the default path ($2) derived from this script's own +# location, so the shim keeps working when the config dir moves or is shared +# across environments with different home directories. +resolve_dir() { + local override + override="$(printenv "$1" 2>/dev/null || true)" + if [[ -n "$override" ]]; then + echo "$override" + else + (cd "$2" 2>/dev/null && pwd) || echo "$2" + fi +} + parse_argv() { if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then tool_data="$(cat "$LLM_TOOL_DATA_FILE")" @@ -28,7 +44,7 @@ setup_env() { export LLM_ROOT_DIR="$root_dir" export LLM_TOOL_NAME="{function_name}" export LLM_TOOL_CACHE_DIR="$LLM_ROOT_DIR/cache/{function_name}" - export LLM_PROMPT_UTILS_FILE="{prompt_utils_file}" + export LLM_PROMPT_UTILS_FILE="$functions_dir/utils/prompt-utils.sh" export LLM_TOOL_RAW_JSON="$tool_data" } @@ -56,6 +72,10 @@ run() { die "error: no JSON data" fi + if [[ ! -f "$tool_path" ]]; then + die "error: tool script not found: $tool_path" + fi + if [[ "$OS" == "Windows_NT" ]]; then set -o igncr tool_path="$(cygpath -w "$tool_path")" diff --git a/assets/functions/scripts/run-tool.ts b/assets/functions/scripts/run-tool.ts index 7580560..8f695a3 100644 --- a/assets/functions/scripts/run-tool.ts +++ b/assets/functions/scripts/run-tool.ts @@ -3,17 +3,41 @@ // Usage: ./{function_name}.ts import { readFileSync, writeFileSync, existsSync } from "fs"; -import { join } from "path"; -import { pathToFileURL } from "url"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; + +function selfDir(): string { + if (typeof __dirname !== "undefined") { + return __dirname; + } + return dirname(fileURLToPath(import.meta.url)); +} + +// Resolve a directory at run time: prefer the override env var when set, +// otherwise fall back to the default path derived from this script's own +// location, so the shim keeps working when the config dir moves or is shared +// across environments with different home directories. +function resolveDir(envName: string, defaultPath: string): string { + const value = process.env[envName]; + if (value) { + return value; + } + return resolve(defaultPath); +} async function main(): Promise { const rawData = parseArgv(); const toolData = parseRawData(rawData); - const rootDir = "{root_dir}"; + const binDir = selfDir(); + const rootDir = resolveDir("{root_dir_env}", join(binDir, "{root_dir_rel}")); + const functionsDir = resolveDir( + "{functions_dir_env}", + join(binDir, "{functions_dir_rel}"), + ); setupEnv(rootDir, rawData); - const toolPath = "{tool_path}.ts"; + const toolPath = join(functionsDir, "tools", "{function_name}.ts"); await run(toolPath, "run", toolData); } diff --git a/src/function/mod.rs b/src/function/mod.rs index 8da65f7..66dadd2 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -1175,6 +1175,35 @@ impl Functions { Self::build_binaries(name, language, BinaryType::Agent, custom_runtime.as_deref()) } + fn render_shim_template( + content_template: &str, + binary_name: &str, + binary_type: &BinaryType, + ) -> String { + let functions_dir_env = get_env_name("functions_dir"); + match binary_type { + BinaryType::Tool(None) => content_template + .replace("{function_name}", binary_name) + .replace("{root_dir_env}", &functions_dir_env) + .replace("{root_dir_rel}", "..") + .replace("{functions_dir_rel}", ".."), + BinaryType::Tool(Some(agent_name)) => content_template + .replace("{function_name}", binary_name) + .replace( + "{root_dir_env}", + &format!("{}_DATA_DIR", normalize_env_name(agent_name)), + ) + .replace("{root_dir_rel}", "..") + .replace("{functions_dir_rel}", "../../../functions"), + BinaryType::Agent => content_template + .replace("{agent_name}", binary_name) + .replace("{root_dir_env}", &get_env_name("config_dir")) + .replace("{root_dir_rel}", "../../..") + .replace("{functions_dir_rel}", "../../../functions"), + } + .replace("{functions_dir_env}", &functions_dir_env) + } + #[cfg(windows)] fn build_binaries( binary_name: &str, @@ -1218,41 +1247,7 @@ impl Functions { ) })?; let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; - let to_script_path = |p: &str| -> String { p.replace('\\', "/") }; - let content = match binary_type { - BinaryType::Tool(None) => { - let root_dir = paths::functions_dir(); - let tool_path = format!( - "{}/{binary_name}", - paths::global_tools_dir().to_string_lossy() - ); - content_template - .replace("{function_name}", binary_name) - .replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy())) - .replace("{tool_path}", &to_script_path(&tool_path)) - } - BinaryType::Tool(Some(agent_name)) => { - let root_dir = paths::agent_data_dir(agent_name); - let tool_path = format!( - "{}/{binary_name}", - paths::global_tools_dir().to_string_lossy() - ); - content_template - .replace("{function_name}", binary_name) - .replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy())) - .replace("{tool_path}", &to_script_path(&tool_path)) - } - BinaryType::Agent => content_template - .replace("{agent_name}", binary_name) - .replace( - "{config_dir}", - &to_script_path(&paths::config_dir().to_string_lossy()), - ), - } - .replace( - "{prompt_utils_file}", - &to_script_path(&paths::bash_prompt_utils_file().to_string_lossy()), - ); + let content = Self::render_shim_template(content_template, binary_name, &binary_type); write_file_atomic(&binary_script_file, &content, None)?; info!( @@ -1298,21 +1293,17 @@ impl Functions { _ => bail!("Unsupported language: {}", language.as_ref()), } }; - let bin_dir = binary_file - .parent() - .expect("Failed to get parent directory of binary file"); - let canonical_bin_dir = dunce::canonicalize(bin_dir)?.to_string_lossy().into_owned(); - let wrapper_binary = dunce::canonicalize(&binary_script_file)? - .to_string_lossy() - .into_owned(); + // %~dp0 (the .cmd's own directory) keeps the launcher relocatable: no + // absolute paths may be baked into it (see render_shim_template). + let script_name = format!("run-{binary_name}.{}", language.to_extension()); let content = formatdoc!( r#" @echo off setlocal - set "bin_dir={canonical_bin_dir}" + set "bin_dir=%~dp0" - {run} "{wrapper_binary}" %*"#, + {run} "%~dp0{script_name}" %*"#, ); write_file_atomic(&binary_file, &content, None)?; @@ -1352,37 +1343,7 @@ impl Functions { ) })?; let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; - let mut content = match binary_type { - BinaryType::Tool(None) => { - let root_dir = paths::functions_dir(); - let tool_path = format!( - "{}/{binary_name}", - paths::global_tools_dir().to_string_lossy() - ); - content_template - .replace("{function_name}", binary_name) - .replace("{root_dir}", &root_dir.to_string_lossy()) - .replace("{tool_path}", &tool_path) - } - BinaryType::Tool(Some(agent_name)) => { - let root_dir = paths::agent_data_dir(agent_name); - let tool_path = format!( - "{}/{binary_name}", - paths::global_tools_dir().to_string_lossy() - ); - content_template - .replace("{function_name}", binary_name) - .replace("{root_dir}", &root_dir.to_string_lossy()) - .replace("{tool_path}", &tool_path) - } - BinaryType::Agent => content_template - .replace("{agent_name}", binary_name) - .replace("{config_dir}", &paths::config_dir().to_string_lossy()), - } - .replace( - "{prompt_utils_file}", - &paths::bash_prompt_utils_file().to_string_lossy(), - ); + let mut content = Self::render_shim_template(content_template, binary_name, &binary_type); if let Some(rt) = custom_runtime && let Some(newline_pos) = content.find('\n') @@ -1398,9 +1359,10 @@ impl Functions { write_file_atomic(&script_file, &content, Some(0o755))?; let ts_runtime = custom_runtime.unwrap_or("tsx"); + // Locate the script next to the wrapper at run time instead of + // baking an absolute path (see render_shim_template). let wrapper = format!( - "#!/bin/sh\nexec {ts_runtime} \"{}\" \"$@\"\n", - script_file.display() + "#!/bin/sh\nexec {ts_runtime} \"$(dirname \"$0\")/run-{binary_name}.ts\" \"$@\"\n", ); write_file_atomic(&binary_file, &wrapper, Some(0o755))?; } else { @@ -4613,6 +4575,97 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + #[cfg(unix)] + #[test] + #[serial] + fn built_shims_resolve_paths_at_runtime_after_relocation() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_file("-shim-relocate-", ""); + let home_a = root.join("home-a"); + let config_a = home_a.join("coyote"); + let tools_dir = config_a.join("functions").join("tools"); + fs::create_dir_all(&tools_dir).unwrap(); + fs::create_dir_all(config_a.join("functions").join("bin")).unwrap(); + let tool_src = tools_dir.join("relocheck.sh"); + fs::write( + &tool_src, + "#!/usr/bin/env bash\necho \"relocated-ok\" >> \"$LLM_OUTPUT\"\n", + ) + .unwrap(); + fs::set_permissions(&tool_src, fs::Permissions::from_mode(0o755)).unwrap(); + + let config_env = get_env_name("config_dir"); + let functions_env = get_env_name("functions_dir"); + let saved_config = env::var(&config_env).ok(); + let saved_functions = env::var(&functions_env).ok(); + unsafe { + env::set_var(&config_env, &config_a); + env::remove_var(&functions_env); + } + + let build_result = Functions::build_global_function_binary("relocheck.sh", None); + + unsafe { + match saved_config { + Some(v) => env::set_var(&config_env, v), + None => env::remove_var(&config_env), + } + match saved_functions { + Some(v) => env::set_var(&functions_env, v), + None => env::remove_var(&functions_env), + } + } + build_result.unwrap(); + + let shim = config_a.join("functions").join("bin").join("relocheck"); + let content = fs::read_to_string(&shim).unwrap(); + assert!( + !content.contains(config_a.to_str().unwrap()), + "shim must not bake the generation-time config path:\n{content}" + ); + assert!( + content.contains(&functions_env), + "shim must consult the functions dir env override at run time:\n{content}" + ); + + // Simulate the config dir landing under a different home directory + // (shared/synced config dir, or a sandbox with another $HOME). + let home_b = root.join("home-b"); + fs::rename(&home_a, &home_b).unwrap(); + let moved_shim = home_b + .join("coyote") + .join("functions") + .join("bin") + .join("relocheck"); + + if which::which("bash").is_err() || which::which("jq").is_err() { + fs::remove_dir_all(&root).unwrap(); + return; + } + + let output = process::Command::new(&moved_shim) + .arg("{}") + .env_remove(&config_env) + .env_remove(&functions_env) + .env_remove("LLM_OUTPUT") + .env_remove("LLM_TOOL_DATA_FILE") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "relocated shim failed: stderr={} stdout={stdout}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("relocated-ok"), + "unexpected output: {stdout}" + ); + + fs::remove_dir_all(&root).unwrap(); + } + #[test] fn eval_tool_calls_partitions_mcp_and_sequential_then_resorts() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);