Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .claude/skills/run-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ notes. Two traps, both of which have bitten before:
(ADR-053)". Observed behavior: the daemon still served the real DB.

**`NODESPACE_HOME` is the correct lever.** It relocates the whole state
directory — database, registry, embedding model — in one variable. Chat models
still load from `$HOME`.
directory — database, registry, embedding model, and `daemon.toml` (provider
settings and API keys) — in one variable. Chat models still load from `$HOME`.
An isolated daemon therefore starts with no API keys; to test against a
provider, put a throwaway `daemon.toml` at `$NODESPACE_HOME/.nodespace/` (never
a symlink or copy of the real one, which the daemon writes to).

## Launch sequence

Expand Down
17 changes: 7 additions & 10 deletions packages/cli/src/commands/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,17 +413,14 @@ pub async fn run(action: Option<McpAction>, sock: PathBuf, database: Option<Stri
}
}

/// Resolves `~/.nodespace/daemon.toml` — the same file
/// `nodespace_daemon::SettingsServiceImpl` reads/writes — independently of
/// the daemon: this whole file is a separate process with no gRPC connection
/// to it, for the server loop ([`run_server`]'s enablement check) as much as
/// for `install`/`uninstall`/`status` below.
/// Resolves `<nodespace_dir>/daemon.toml` — the same file
/// `nodespace_daemon::SettingsServiceImpl` reads/writes, through the same
/// resolver, so `NODESPACE_HOME` isolates both — independently of the daemon:
/// this whole file is a separate process with no gRPC connection to it, for
/// the server loop ([`run_server`]'s enablement check) as much as for
/// `install`/`uninstall`/`status` below.
fn daemon_config_path() -> Result<PathBuf> {
let home =
std::env::var("HOME").context("$HOME is unset — cannot locate NodeSpace settings")?;
Ok(PathBuf::from(home)
.join(nodespace_proto::socket::STATE_DIR)
.join("daemon.toml"))
Ok(nodespace_daemon::nodespace_dir()?.join("daemon.toml"))
}

/// Prompt on a real terminal; auto-confirm (stating that the prompt was
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/tests/it/mcp_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ async fn mcp_speaks_stdio_jsonrpc_and_exposes_exactly_one_tool() {
let mut child = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -156,6 +157,7 @@ async fn mcp_tool_call_reports_daemon_unreachable_actionably_not_as_a_raw_connec
.arg(&sock)
.arg("mcp")
.env("HOME", tempdir.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -209,6 +211,7 @@ async fn mcp_tool_call_rejects_an_unterminated_quote_without_dispatching_anythin
let mut child = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -255,6 +258,7 @@ async fn mcp_refuses_to_start_when_not_enabled() {
let child = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -301,6 +305,7 @@ async fn mcp_refuses_to_start_when_explicitly_disabled() {
let child = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -385,6 +390,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
.arg("mcp")
.arg("status")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.output()
.await
.expect("run mcp status");
Expand All @@ -398,6 +404,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
.arg("install")
.arg("--yes")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.env("PATH", &path_with_fake_nodespace)
.output()
.await
Expand Down Expand Up @@ -439,6 +446,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
.arg("mcp")
.arg("status")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.output()
.await
.expect("run mcp status");
Expand All @@ -458,6 +466,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
let mut child = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand All @@ -480,6 +489,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
.arg("mcp")
.arg("uninstall")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.output()
.await
.expect("run mcp uninstall");
Expand Down Expand Up @@ -515,6 +525,7 @@ async fn mcp_install_uninstall_round_trip_against_the_real_installer_enables_and
let output = Command::new(env!("CARGO_BIN_EXE_nodespace"))
.arg("mcp")
.env("HOME", home.path())
.env_remove("NODESPACE_HOME")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down
116 changes: 111 additions & 5 deletions packages/daemon/src/services/settings_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,12 +523,17 @@ impl SettingsServiceImpl {
}
}

/// Build with the default path `~/.nodespace/daemon.toml`.
/// Build with the default path `<nodespace_dir>/daemon.toml`.
///
/// Resolved through [`crate::nodespace_dir`], so it follows `NODESPACE_HOME`
/// exactly as the database, the registry, the embedding model and the other
/// `daemon.toml` readers do. The file holds API keys and is written to (the
/// routing probe caches verdicts in it), so an isolated daemon must neither
/// read nor mutate the user's own copy.
pub fn with_default_path() -> Result<Self, String> {
let home = std::env::var("HOME")
.map_err(|_| "$HOME is unset — cannot locate daemon config".to_string())?;
let path = PathBuf::from(home).join(".nodespace").join("daemon.toml");
Ok(Self::new(path))
let dir =
crate::nodespace_dir().map_err(|e| format!("cannot locate daemon config: {e}"))?;
Ok(Self::new(dir.join("daemon.toml")))
}

/// Create (or truncate) the temp file at `path`, already restricted to
Expand Down Expand Up @@ -725,6 +730,107 @@ mod tests {
(SettingsServiceImpl::new(config_path), tempdir)
}

/// Restores `NODESPACE_HOME` and `HOME` on drop, so a panic cannot leak them.
/// The environment is process-global: nextest runs each test in its own
/// process, and the window is the one synchronous call.
struct EnvRestore(Vec<(&'static str, Option<std::ffi::OsString>)>);

impl Drop for EnvRestore {
fn drop(&mut self) {
for (var, value) in self.0.drain(..) {
match value {
Some(value) => std::env::set_var(var, value),
None => std::env::remove_var(var),
}
}
}
}

/// `with_default_path` with `NODESPACE_HOME` at `nodespace_home` and `HOME`
/// at `user_home`.
fn default_path_service(
nodespace_home: &std::path::Path,
user_home: &std::path::Path,
) -> SettingsServiceImpl {
let _restore = EnvRestore(
["NODESPACE_HOME", "HOME"]
.map(|v| (v, std::env::var_os(v)))
.into(),
);
std::env::set_var("NODESPACE_HOME", nodespace_home);
std::env::set_var("HOME", user_home);
SettingsServiceImpl::with_default_path().expect("default path")
}

/// A "real" home holding a `daemon.toml` with one OpenAI-compat config.
fn home_with_settings() -> (tempfile::TempDir, std::path::PathBuf) {
let home = tempfile::tempdir().expect("home tempdir");
let dir = home.path().join(".nodespace");
std::fs::create_dir_all(&dir).expect("state dir");
let file = dir.join("daemon.toml");
std::fs::write(
&file,
"[[openai_compat.configs]]\nid = \"real-id\"\nname = \"real\"\nbase_url = \"http://real\"\napi_key = \"real-secret\"\n",
)
.expect("write real settings");
(home, file)
}

/// An isolated daemon neither reads nor rewrites the user's settings file,
/// even though `HOME` points at one that holds an API key.
#[tokio::test]
async fn default_path_under_nodespace_home_ignores_the_user_home() {
let (user_home, real_file) = home_with_settings();
let before = std::fs::read(&real_file).expect("read real");
let isolated = tempfile::tempdir().expect("isolated home");
let svc = default_path_service(isolated.path(), user_home.path());

assert_eq!(
svc.config_path,
isolated.path().join(".nodespace").join("daemon.toml")
);
let listed = svc
.list_open_ai_compat_configs(Request::new(ListOpenAiCompatConfigsRequest {}))
.await
.expect("list")
.into_inner();
assert!(listed.configs.is_empty(), "must not see the user's configs");

svc.set_open_ai_compat_configs(Request::new(SetOpenAiCompatConfigsRequest {
configs: vec![probe_config("iso", "http://iso", "m")],
}))
.await
.expect("write");
assert_eq!(std::fs::read(&real_file).expect("re-read real"), before);
assert!(isolated
.path()
.join(".nodespace")
.join("daemon.toml")
.exists());
}

/// A settings file under `NODESPACE_HOME` is the one used, not the user's.
#[tokio::test]
async fn default_path_reads_the_file_under_nodespace_home() {
let (user_home, _) = home_with_settings();
let (isolated, _) = home_with_settings();
let file = isolated.path().join(".nodespace").join("daemon.toml");
std::fs::write(
&file,
"[[openai_compat.configs]]\nid = \"iso-id\"\nname = \"iso\"\nbase_url = \"http://iso\"\napi_key = \"iso-secret\"\n",
)
.unwrap();
let svc = default_path_service(isolated.path(), user_home.path());

let listed = svc
.list_open_ai_compat_configs(Request::new(ListOpenAiCompatConfigsRequest {}))
.await
.expect("list")
.into_inner();
assert_eq!(listed.configs.len(), 1);
assert_eq!(listed.configs[0].id, "iso-id");
}

#[tokio::test]
async fn list_openai_compat_configs_empty_when_no_file() {
let (svc, _tempdir) = test_impl();
Expand Down
5 changes: 3 additions & 2 deletions scripts/eval/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,8 +596,9 @@ export function preflight(
` This eval creates schemas, chat nodes, and instances; running it here would ` +
`write test data into live user data.`,
`Isolate the daemon with NODESPACE_HOME (not NODESPACED_DB_PATH, which the\n` +
` database registry overrides). The embedding model follows NODESPACE_HOME\n` +
` too, so name your own copy for skill retrieval:\n` +
` database registry overrides). The embedding model and daemon.toml (API\n` +
` keys) follow NODESPACE_HOME too, so the isolated daemon holds no provider\n` +
` keys. Name your own model copy for skill retrieval:\n` +
` NODESPACE_HOME=/tmp/nodespaced-test \\\n` +
` NODESPACED_MODEL_PATH=$HOME/.nodespace/models/nomic-embed-text-v1.5.Q8_0.gguf \\\n` +
` NODESPACED_SOCKET=${env.socket} target/release/nodespaced\n` +
Expand Down