fix: make binary shims for custom tools cross-device compatible so users can't accidentally break sandboxes via sbx cp ~/.config/coyote <sbx-name>:/home/agent/.config

This commit is contained in:
2026-08-31 13:48:56 -06:00
parent c102da5665
commit 016f2654e4
7 changed files with 278 additions and 103 deletions
+21 -5
View File
@@ -27,14 +27,30 @@ def _ensure_cwd_venv():
_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(): def main():
(agent_func, raw_data) = parse_argv() (agent_func, raw_data) = parse_argv()
agent_data = parse_raw_data(raw_data) agent_data = parse_raw_data(raw_data)
root_dir = "{config_dir}" self_dir = os.path.dirname(os.path.abspath(__file__))
setup_env(root_dir, agent_func, raw_data) 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) run(agent_tools_path, agent_func, agent_data)
@@ -65,12 +81,12 @@ def parse_argv():
return agent_func, agent_data 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")) load_env(os.path.join(root_dir, ".env"))
os.environ["LLM_ROOT_DIR"] = root_dir os.environ["LLM_ROOT_DIR"] = root_dir
os.environ["LLM_AGENT_NAME"] = "{agent_name}" os.environ["LLM_AGENT_NAME"] = "{agent_name}"
os.environ["LLM_AGENT_FUNC"] = agent_func 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_CACHE_DIR"] = os.path.join(root_dir, "cache", "{agent_name}")
os.environ["LLM_AGENT_RAW_JSON"] = raw_data os.environ["LLM_AGENT_RAW_JSON"] = raw_data
+25 -5
View File
@@ -5,13 +5,30 @@
set -e set -e
main() { 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 "$@" parse_argv "$@"
setup_env setup_env
tools_path="$root_dir/agents/{agent_name}/tools.sh" tools_path="$agent_dir/tools.sh"
run 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() { parse_argv() {
agent_func="$1" agent_func="$1"
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then 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_ROOT_DIR="$root_dir"
export LLM_AGENT_NAME="{agent_name}" export LLM_AGENT_NAME="{agent_name}"
export LLM_AGENT_FUNC="$agent_func" 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_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" export LLM_AGENT_RAW_JSON="$agent_data"
} }
@@ -59,6 +76,10 @@ run() {
die "error: no JSON data" die "error: no JSON data"
fi fi
if [[ ! -f "$tools_path" ]]; then
die "error: agent tools script not found: $tools_path"
fi
if [[ "$OS" == "Windows_NT" ]]; then if [[ "$OS" == "Windows_NT" ]]; then
set -o igncr set -o igncr
tools_path="$(cygpath -w "$tools_path")" tools_path="$(cygpath -w "$tools_path")"
@@ -122,4 +143,3 @@ die() {
} }
main "$@" main "$@"
+33 -7
View File
@@ -3,17 +3,38 @@
// Usage: ./{agent_name}.ts <agent-func> <agent-data> // Usage: ./{agent_name}.ts <agent-func> <agent-data>
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path"; import { dirname, join, resolve } from "path";
import { pathToFileURL } from "url"; 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<void> { async function main(): Promise<void> {
const { agentFunc, rawData } = parseArgv(); const { agentFunc, rawData } = parseArgv();
const agentData = parseRawData(rawData); const agentData = parseRawData(rawData);
const configDir = "{config_dir}"; const binDir = selfDir();
setupEnv(configDir, agentFunc, rawData); 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); await run(agentToolsPath, agentFunc, agentData);
} }
@@ -48,12 +69,17 @@ function parseArgv(): { agentFunc: string; rawData: string } {
return { agentFunc, rawData: agentData }; 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")); loadEnv(join(configDir, ".env"));
process.env["LLM_ROOT_DIR"] = configDir; process.env["LLM_ROOT_DIR"] = configDir;
process.env["LLM_AGENT_NAME"] = "{agent_name}"; process.env["LLM_AGENT_NAME"] = "{agent_name}";
process.env["LLM_AGENT_FUNC"] = agentFunc; 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_CACHE_DIR"] = join(configDir, "cache", "{agent_name}");
process.env["LLM_AGENT_RAW_JSON"] = rawData; process.env["LLM_AGENT_RAW_JSON"] = rawData;
} }
+18 -2
View File
@@ -27,14 +27,30 @@ def _ensure_cwd_venv():
_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(): def main():
raw_data = parse_argv() raw_data = parse_argv()
tool_data = parse_raw_data(raw_data) 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) 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) run(tool_path, "run", tool_data)
+23 -3
View File
@@ -5,13 +5,29 @@
set -e set -e
main() { 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 "$@" parse_argv "$@"
setup_env setup_env
tool_path="{tool_path}.sh" tool_path="$functions_dir/tools/{function_name}.sh"
run 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() { parse_argv() {
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then
tool_data="$(cat "$LLM_TOOL_DATA_FILE")" tool_data="$(cat "$LLM_TOOL_DATA_FILE")"
@@ -28,7 +44,7 @@ setup_env() {
export LLM_ROOT_DIR="$root_dir" export LLM_ROOT_DIR="$root_dir"
export LLM_TOOL_NAME="{function_name}" export LLM_TOOL_NAME="{function_name}"
export LLM_TOOL_CACHE_DIR="$LLM_ROOT_DIR/cache/{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" export LLM_TOOL_RAW_JSON="$tool_data"
} }
@@ -56,6 +72,10 @@ run() {
die "error: no JSON data" die "error: no JSON data"
fi fi
if [[ ! -f "$tool_path" ]]; then
die "error: tool script not found: $tool_path"
fi
if [[ "$OS" == "Windows_NT" ]]; then if [[ "$OS" == "Windows_NT" ]]; then
set -o igncr set -o igncr
tool_path="$(cygpath -w "$tool_path")" tool_path="$(cygpath -w "$tool_path")"
+28 -4
View File
@@ -3,17 +3,41 @@
// Usage: ./{function_name}.ts <tool-data> // Usage: ./{function_name}.ts <tool-data>
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path"; import { dirname, join, resolve } from "path";
import { pathToFileURL } from "url"; 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<void> { async function main(): Promise<void> {
const rawData = parseArgv(); const rawData = parseArgv();
const toolData = parseRawData(rawData); 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); setupEnv(rootDir, rawData);
const toolPath = "{tool_path}.ts"; const toolPath = join(functionsDir, "tools", "{function_name}.ts");
await run(toolPath, "run", toolData); await run(toolPath, "run", toolData);
} }
+130 -77
View File
@@ -1175,6 +1175,35 @@ impl Functions {
Self::build_binaries(name, language, BinaryType::Agent, custom_runtime.as_deref()) 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)] #[cfg(windows)]
fn build_binaries( fn build_binaries(
binary_name: &str, binary_name: &str,
@@ -1218,41 +1247,7 @@ impl Functions {
) )
})?; })?;
let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let to_script_path = |p: &str| -> String { p.replace('\\', "/") }; let content = Self::render_shim_template(content_template, binary_name, &binary_type);
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()),
);
write_file_atomic(&binary_script_file, &content, None)?; write_file_atomic(&binary_script_file, &content, None)?;
info!( info!(
@@ -1298,21 +1293,17 @@ impl Functions {
_ => bail!("Unsupported language: {}", language.as_ref()), _ => bail!("Unsupported language: {}", language.as_ref()),
} }
}; };
let bin_dir = binary_file // %~dp0 (the .cmd's own directory) keeps the launcher relocatable: no
.parent() // absolute paths may be baked into it (see render_shim_template).
.expect("Failed to get parent directory of binary file"); let script_name = format!("run-{binary_name}.{}", language.to_extension());
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();
let content = formatdoc!( let content = formatdoc!(
r#" r#"
@echo off @echo off
setlocal 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)?; 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 content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let mut content = match binary_type { let mut content = Self::render_shim_template(content_template, binary_name, &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(),
);
if let Some(rt) = custom_runtime if let Some(rt) = custom_runtime
&& let Some(newline_pos) = content.find('\n') && let Some(newline_pos) = content.find('\n')
@@ -1398,9 +1359,10 @@ impl Functions {
write_file_atomic(&script_file, &content, Some(0o755))?; write_file_atomic(&script_file, &content, Some(0o755))?;
let ts_runtime = custom_runtime.unwrap_or("tsx"); 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!( let wrapper = format!(
"#!/bin/sh\nexec {ts_runtime} \"{}\" \"$@\"\n", "#!/bin/sh\nexec {ts_runtime} \"$(dirname \"$0\")/run-{binary_name}.ts\" \"$@\"\n",
script_file.display()
); );
write_file_atomic(&binary_file, &wrapper, Some(0o755))?; write_file_atomic(&binary_file, &wrapper, Some(0o755))?;
} else { } else {
@@ -4613,6 +4575,97 @@ mod tests {
fs::remove_dir_all(&dir).unwrap(); 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] #[test]
fn eval_tool_calls_partitions_mcp_and_sequential_then_resorts() { fn eval_tool_calls_partitions_mcp_and_sequential_then_resorts() {
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);