Keep a typed path as typed, and expand ~ where it is used

A llama session's tools all answered "failed to spawn process
[exit code: -1]": `llama-server` takes the working directory as an
`x-tool-cwd` header and `chdir`s to it with no shell in the way, so a
`~/…` cwd named a directory of that name. `files::resolve_blocking`
asks the machine that will serve the session what the path is, and the
driver does that once at launch.

The other half is the storing. `~` and `/home/someone` are a path and a
snapshot of where it pointed, and it is the snapshot that breaks when an
account is renamed -- so `machines::tidy` no longer expands one and
`set_cwd` no longer contracts one (`shorten_home` is gone with it). The
identity file is expanded at the point `ssh` is invoked instead.

Spawn now asks the same question of a typed working directory that
`set_cwd` already did: absolute or home-relative, and actually there on
the machine that will run it. It accepted anything, so a typo became a
session whose process could not start, reported later and pointing at
nothing.

Also: a provider written before `mcp_servers` existed adopts the
defaults a probe would give it now, so a machine discovered before
2026-09-19 stops silently having no web search.

Exercised end to end against a real llama session spawned with
`cwd: "~/repos/ai-app/server"`: `exec_shell_command` with `pwd` answered
`/home/bob/repos/ai-app/server`, exit 0.
This commit is contained in:
iris-ai committed 2026-09-20 17:06:13 -04:00
1 parent bd9596d782
commit 3b309766d7
8 files changed
+201 -101

No files matched your search

+52
View File
@@ -176,6 +176,34 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
})
}
/// The absolute path `path` names on that machine, with a leading `~` expanded
/// *there*.
///
/// What [`list`] answers as its `path`, asked on its own: a caller that has to
/// hand a directory to something with no shell in front of it needs the
/// resolved form and nothing else. `llama-server` is the one such caller --
/// its tools take a working directory as a request header and `chdir` to it
/// literally, so the `~` that every other path in this server carries through
/// to the far side's shell arrived there as a directory called `~`, and every
/// tool that uses one failed with "failed to spawn process".
///
/// Blocking because a driver's launch is, and this is a question for the
/// machine that will serve the session rather than for this one: a remote
/// `~` is the remote home, and expanding it here would name a directory on
/// the wrong machine -- which is also why the answer is not cached anywhere
/// but on the driver that asked.
pub fn resolve_blocking(transport: &Transport, path: &str) -> Result<String> {
let script = format!("{PATH_PRELUDE}cd -- \"$p\" && pwd -P");
let out = transport.capture_blocking(&launch(script, path, None))?;
// Only the newline `pwd` ends with: a directory name may legitimately end
// in a space, and trimming whitespace would rename it.
let resolved = out.trim_end_matches('\n');
if resolved.is_empty() {
anyhow::bail!("the machine did not say what {path} resolves to");
}
Ok(resolved.to_string())
}
/// The `find` output above, as rows. A record without all five fields is
/// dropped rather than guessed at: it can only come from a `find` that printed
/// something else, and half a row is worse than no row.
@@ -436,6 +464,30 @@ mod tests {
assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]);
}
/// The regression `resolve_blocking` exists for: a working directory typed
/// as `~/…` reaches `llama-server` as a header it `chdir`s to, so it has to
/// arrive absolute or every tool that uses one fails to spawn.
#[test]
fn a_tilde_resolves_to_that_machine_s_home() {
let Some(home) = std::env::home_dir() else {
return;
};
let resolved = resolve_blocking(&Transport::Here, "~").unwrap();
assert!(resolved.starts_with('/'), "{resolved}");
assert_eq!(
std::fs::canonicalize(&resolved).unwrap(),
std::fs::canonicalize(&home).unwrap(),
);
let dir = tree();
// An absolute path is answered as itself, resolved.
let full = dir.path().to_string_lossy().into_owned();
assert_eq!(
resolve_blocking(&Transport::Here, &full).unwrap(),
std::fs::canonicalize(&full).unwrap().to_string_lossy(),
);
assert!(resolve_blocking(&Transport::Here, &at(&dir, "nope")).is_err());
}
#[tokio::test]
async fn a_missing_directory_fails_with_the_machine_s_own_message() {
let dir = tree();