diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 6b2d38197..a61da9dd3 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -340,6 +340,67 @@ class TestSpawnEnvSanitization: assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env assert env["PYTHONUNBUFFERED"] == "1" + def test_spawn_via_env_uses_backend_temp_dir_for_artifacts(self, registry): + class FakeEnv: + def __init__(self): + self.commands = [] + + def get_temp_dir(self): + return "/data/data/com.termux/files/usr/tmp" + + def execute(self, command, timeout=None): + self.commands.append((command, timeout)) + return {"output": "4321\n"} + + env = FakeEnv() + fake_thread = MagicMock() + + with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \ + patch.object(registry, "_write_checkpoint"): + session = registry.spawn_via_env(env, "echo hello") + + bg_command = env.commands[0][0] + assert session.pid == 4321 + assert "/data/data/com.termux/files/usr/tmp/hermes_bg_" in bg_command + assert ".exit" in bg_command + assert "rc=$?;" in bg_command + assert " > /tmp/hermes_bg_" not in bg_command + assert "cat /tmp/hermes_bg_" not in bg_command + fake_thread.start.assert_called_once() + + def test_env_poller_quotes_temp_paths_with_spaces(self, registry): + session = _make_session(sid="proc_space") + session.exited = False + + class FakeEnv: + def __init__(self): + self.commands = [] + self._responses = iter([ + {"output": "hello\n"}, + {"output": "1\n"}, + {"output": "0\n"}, + ]) + + def execute(self, command, timeout=None): + self.commands.append((command, timeout)) + return next(self._responses) + + env = FakeEnv() + + with patch("tools.process_registry.time.sleep", return_value=None), \ + patch.object(registry, "_move_to_finished"): + registry._env_poller_loop( + session, + env, + "/path with spaces/hermes_bg.log", + "/path with spaces/hermes_bg.pid", + "/path with spaces/hermes_bg.exit", + ) + + assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null" + assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?" + assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null" + # ========================================================================= # Checkpoint diff --git a/tools/process_registry.py b/tools/process_registry.py index c954378bd..7f55ae6db 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -172,6 +172,19 @@ class ProcessRegistry: # ----- Spawn ----- + @staticmethod + def _env_temp_dir(env: Any) -> str: + """Return the writable sandbox temp dir for env-backed background tasks.""" + get_temp_dir = getattr(env, "get_temp_dir", None) + if callable(get_temp_dir): + try: + temp_dir = get_temp_dir() + if isinstance(temp_dir, str) and temp_dir.startswith("/"): + return temp_dir.rstrip("/") or "/" + except Exception as exc: + logger.debug("Could not resolve environment temp dir: %s", exc) + return "/tmp" + def spawn_local( self, command: str, @@ -316,12 +329,20 @@ class ProcessRegistry: ) # Run the command in the sandbox with output capture - log_path = f"/tmp/hermes_bg_{session.id}.log" - pid_path = f"/tmp/hermes_bg_{session.id}.pid" + temp_dir = self._env_temp_dir(env) + log_path = f"{temp_dir}/hermes_bg_{session.id}.log" + pid_path = f"{temp_dir}/hermes_bg_{session.id}.pid" + exit_path = f"{temp_dir}/hermes_bg_{session.id}.exit" quoted_command = shlex.quote(command) + quoted_temp_dir = shlex.quote(temp_dir) + quoted_log_path = shlex.quote(log_path) + quoted_pid_path = shlex.quote(pid_path) + quoted_exit_path = shlex.quote(exit_path) bg_command = ( - f"nohup bash -c {quoted_command} > {log_path} 2>&1 & " - f"echo $! > {pid_path} && cat {pid_path}" + f"mkdir -p {quoted_temp_dir} && " + f"( nohup bash -lc {quoted_command} > {quoted_log_path} 2>&1; " + f"rc=$?; printf '%s\\n' \"$rc\" > {quoted_exit_path} ) & " + f"echo $! > {quoted_pid_path} && cat {quoted_pid_path}" ) try: @@ -342,7 +363,7 @@ class ProcessRegistry: # Start a poller thread that periodically reads the log file reader = threading.Thread( target=self._env_poller_loop, - args=(session, env, log_path, pid_path), + args=(session, env, log_path, pid_path, exit_path), daemon=True, name=f"proc-poller-{session.id}", ) @@ -386,14 +407,17 @@ class ProcessRegistry: self._move_to_finished(session) def _env_poller_loop( - self, session: ProcessSession, env: Any, log_path: str, pid_path: str + self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str ): """Background thread: poll a sandbox log file for non-local backends.""" + quoted_log_path = shlex.quote(log_path) + quoted_pid_path = shlex.quote(pid_path) + quoted_exit_path = shlex.quote(exit_path) while not session.exited: time.sleep(2) # Poll every 2 seconds try: # Read new output from the log file - result = env.execute(f"cat {log_path} 2>/dev/null", timeout=10) + result = env.execute(f"cat {quoted_log_path} 2>/dev/null", timeout=10) new_output = result.get("output", "") if new_output: with session._lock: @@ -403,14 +427,14 @@ class ProcessRegistry: # Check if process is still running check = env.execute( - f"kill -0 $(cat {pid_path} 2>/dev/null) 2>/dev/null; echo $?", + f"kill -0 \"$(cat {quoted_pid_path} 2>/dev/null)\" 2>/dev/null; echo $?", timeout=5, ) check_output = check.get("output", "").strip() if check_output and check_output.splitlines()[-1].strip() != "0": - # Process has exited -- get exit code + # Process has exited -- get exit code captured by the wrapper shell. exit_result = env.execute( - f"wait $(cat {pid_path} 2>/dev/null) 2>/dev/null; echo $?", + f"cat {quoted_exit_path} 2>/dev/null", timeout=5, ) exit_str = exit_result.get("output", "").strip()