# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
575 lines
24 KiB
Python
575 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for execute_code's session kernel.
|
|
|
|
Session kernels are always on (the ``code_execution.kernel_mode`` key is
|
|
retired): each (task, mode, interpreter, cwd, tool-set) owner keeps one
|
|
Python child alive so state survives across calls. These tests pin the
|
|
contract:
|
|
|
|
- state persists across cells and reset=true discards it
|
|
- a raised exception keeps the kernel (and its state) alive
|
|
- a timeout kills the kernel; the next call gets a fresh one
|
|
- fd-level output from user-spawned subprocesses reaches the result
|
|
- sys.exit() inside a cell ends the kernel deliberately
|
|
|
|
Mode is sourced from ``code_execution.mode`` in config.yaml only;
|
|
tests patch ``_load_config`` directly, mirroring test_code_execution_modes.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import time
|
|
import unittest
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
os.environ["TERMINAL_ENV"] = "local"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _force_local_terminal(monkeypatch):
|
|
"""Mirror test_code_execution.py — guarantee local backend."""
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
|
|
|
|
from tools.code_execution_tool import execute_code
|
|
from tools.code_kernel import _KERNELS, shutdown_all_kernels
|
|
|
|
|
|
@contextmanager
|
|
def _kernel_config(**overrides):
|
|
"""Pin code_execution config; strict mode keeps the test hermetic.
|
|
``mode`` (strict/project) is the only config knob — session kernels are
|
|
always on; the retired ``kernel_mode`` key is ignored by the tool."""
|
|
config = {"mode": "strict", "timeout": 30}
|
|
config.update(overrides)
|
|
with patch("tools.code_execution_tool._load_config", return_value=config):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_kernel_registry():
|
|
shutdown_all_kernels()
|
|
yield
|
|
shutdown_all_kernels()
|
|
|
|
|
|
def _run(code, **kwargs):
|
|
return json.loads(execute_code(code, task_id="kernel-test", **kwargs))
|
|
|
|
|
|
class TestSessionStatePersistence(unittest.TestCase):
|
|
def test_state_persists_across_cells(self):
|
|
with _kernel_config():
|
|
first = _run("x = 41")
|
|
self.assertEqual(first["status"], "success", first)
|
|
self.assertEqual(first["kernel"]["reused"], False)
|
|
second = _run("print(x + 1)")
|
|
self.assertEqual(second["status"], "success", second)
|
|
self.assertIn("42", second["output"])
|
|
self.assertEqual(second["kernel"]["reused"], True)
|
|
self.assertEqual(second["kernel"]["execution_count"], 2)
|
|
|
|
def test_reset_discards_state(self):
|
|
with _kernel_config():
|
|
_run("x = 41")
|
|
second = _run("print(x + 1)", reset=True)
|
|
self.assertEqual(second["status"], "error", second)
|
|
self.assertIn("NameError", second.get("error", ""))
|
|
self.assertEqual(second["kernel"]["state_reset"], True)
|
|
|
|
def test_exception_keeps_the_kernel_alive(self):
|
|
with _kernel_config():
|
|
_run("a = 7")
|
|
boom = _run("1 / 0")
|
|
self.assertEqual(boom["status"], "error")
|
|
self.assertIn("ZeroDivisionError", boom["error"])
|
|
after = _run("print(a)")
|
|
self.assertEqual(after["status"], "success", after)
|
|
self.assertIn("7", after["output"])
|
|
self.assertEqual(after["kernel"]["reused"], True)
|
|
|
|
def test_imports_persist(self):
|
|
with _kernel_config():
|
|
_run("import json as _j")
|
|
second = _run("print(_j.dumps({'k': 1}))")
|
|
self.assertIn('{"k": 1}', second["output"])
|
|
|
|
|
|
class TestKernelLifecycle(unittest.TestCase):
|
|
def test_kernel_exits_when_its_backend_parent_dies(self):
|
|
"""A kernel must not outlive the host that spawned it, even when the
|
|
host dies without cleanup (SIGKILL/OOM/crash). Windows: inherited
|
|
SYNCHRONIZE handle; POSIX: inherited death pipe. Both are proven the
|
|
same way — kill the host mid-cell, the kernel is gone within seconds."""
|
|
import psutil
|
|
|
|
repo_root = str(Path(__file__).resolve().parents[2])
|
|
host_src = textwrap.dedent(f"""
|
|
import json, os, sys, time
|
|
os.environ["HERMES_HOME"] = sys.argv[1]
|
|
sys.path.insert(0, {repo_root!r})
|
|
from tools.code_kernel import SessionKernel, _spawn
|
|
k = SessionKernel(("parent-death",))
|
|
_spawn(k, task_id="parent-death", child_python=sys.executable,
|
|
child_cwd="", sandbox_tools=frozenset(), max_tool_calls=1)
|
|
cell = json.dumps({{"id": "x", "code": "import os, time\\n"
|
|
"assert 'HERMES_KERNEL_PARENT_PROCESS_HANDLE' not in os.environ\\n"
|
|
"assert 'HERMES_KERNEL_PARENT_DEATH_FD' not in os.environ\\n"
|
|
"time.sleep(300)"}}) + "\\n"
|
|
k.proc.stdin.write(cell.encode()); k.proc.stdin.flush()
|
|
print(k.proc.pid, flush=True)
|
|
time.sleep(600)
|
|
""")
|
|
with tempfile.TemporaryDirectory() as home:
|
|
host = subprocess.Popen(
|
|
[sys.executable, "-c", host_src, home],
|
|
stdout=subprocess.PIPE, text=True,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
try:
|
|
kernel = psutil.Process(int(host.stdout.readline()))
|
|
time.sleep(0.5)
|
|
self.assertTrue(kernel.is_running(), "kernel never came up")
|
|
host.kill()
|
|
host.wait(timeout=10)
|
|
try:
|
|
kernel.wait(timeout=10)
|
|
except psutil.TimeoutExpired:
|
|
kernel.kill()
|
|
self.fail("session kernel survived its backend parent")
|
|
finally:
|
|
if host.poll() is None:
|
|
host.kill()
|
|
|
|
def test_timeout_kills_the_kernel_and_reports_state_loss(self):
|
|
with _kernel_config(timeout=1):
|
|
slow = _run("import time\ntime.sleep(30)")
|
|
self.assertEqual(slow["status"], "timeout", slow)
|
|
self.assertIn("state was lost", slow["error"])
|
|
self.assertEqual(len(_KERNELS), 0)
|
|
with _kernel_config():
|
|
fresh = _run("print('alive')")
|
|
self.assertEqual(fresh["status"], "success", fresh)
|
|
self.assertEqual(fresh["kernel"]["reused"], False)
|
|
self.assertIn("alive", fresh["output"])
|
|
|
|
def test_sys_exit_ends_the_kernel(self):
|
|
with _kernel_config():
|
|
done = _run("import sys\nsys.exit(0)")
|
|
self.assertEqual(done["kernel"].get("ended"), True, done)
|
|
self.assertEqual(len(_KERNELS), 0)
|
|
fresh = _run("print('respawned')")
|
|
self.assertEqual(fresh["kernel"]["reused"], False)
|
|
self.assertIn("respawned", fresh["output"])
|
|
|
|
def test_subprocess_fd_output_reaches_the_result(self):
|
|
code = (
|
|
"import subprocess, sys\n"
|
|
"subprocess.run([sys.executable, '-c', \"print('raw-passthrough')\"])\n"
|
|
)
|
|
with _kernel_config():
|
|
result = _run(code)
|
|
self.assertEqual(result["status"], "success", result)
|
|
self.assertIn("raw-passthrough", result["output"])
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-v"]))
|
|
|
|
|
|
class TestKernelOwnershipAndLifecycle(unittest.TestCase):
|
|
"""The kernel belongs to the conversation, and its lifetime is bounded.
|
|
|
|
run_agent mints a fresh task id per top-level turn, so a task-keyed
|
|
kernel would neither survive the next user turn nor ever be disposed
|
|
with anything. The owner is the approval session key; disposal rides
|
|
the same session boundary that clears approval/yolo state, idle
|
|
kernels are reaped, and the process-wide live count is capped (the
|
|
lifecycle shape carried forward from hermes-agent#88637).
|
|
"""
|
|
|
|
def _run_as(self, session_key, code, task_id, **kwargs):
|
|
from tools.approval_context import reset_current_session_key, set_current_session_key
|
|
|
|
token = set_current_session_key(session_key)
|
|
try:
|
|
return json.loads(execute_code(code, task_id=task_id, **kwargs))
|
|
finally:
|
|
reset_current_session_key(token)
|
|
|
|
def test_state_survives_across_turns_of_one_conversation(self):
|
|
# Two top-level turns: same session, different per-turn task ids.
|
|
with _kernel_config():
|
|
first = self._run_as("conv-a", "x = 41", task_id="turn-1")
|
|
self.assertEqual(first["status"], "success", first)
|
|
second = self._run_as("conv-a", "print(x + 1)", task_id="turn-2")
|
|
self.assertEqual(second["status"], "success", second)
|
|
self.assertIn("42", second["output"])
|
|
self.assertEqual(second["kernel"]["reused"], True)
|
|
|
|
def test_sessions_are_isolated_from_each_other(self):
|
|
# Same task id, different sessions: no state may cross.
|
|
with _kernel_config():
|
|
self._run_as("conv-a", "x = 41", task_id="turn-1")
|
|
other = self._run_as("conv-b", "print(x + 1)", task_id="turn-1")
|
|
self.assertEqual(other["status"], "error", other)
|
|
self.assertIn("NameError", other.get("error", ""))
|
|
|
|
def test_delegated_children_get_their_own_kernels(self):
|
|
"""A delegated child runs in a COPY of the parent's context and
|
|
inherits the parent's approval session key — the naive owner
|
|
resolution attached the child to the parent's kernel and leaked
|
|
in-memory state across the delegation boundary (both directions,
|
|
verified live). The owner must be qualified for child contexts."""
|
|
from agent.delegation_context import delegated_child_context
|
|
|
|
with _kernel_config():
|
|
self._run_as("conv-a", "parent_secret = 'p'", task_id="turn-1")
|
|
with delegated_child_context("child-1"):
|
|
leak = self._run_as(
|
|
"conv-a",
|
|
"print(globals().get('parent_secret', 'ISOLATED'))",
|
|
task_id="child-task",
|
|
)
|
|
self._run_as("conv-a", "child_secret = 'c'", task_id="child-task")
|
|
back = self._run_as(
|
|
"conv-a",
|
|
"print(globals().get('child_secret', 'ISOLATED'))",
|
|
task_id="turn-2",
|
|
)
|
|
self.assertIn("ISOLATED", leak.get("output", ""), leak)
|
|
self.assertIn("ISOLATED", back.get("output", ""), back)
|
|
|
|
def test_two_delegated_children_are_isolated_from_each_other(self):
|
|
"""Sibling children in one batch must not share a kernel either —
|
|
each child context carries its own delegation session id."""
|
|
from agent.delegation_context import delegated_child_context
|
|
|
|
with _kernel_config():
|
|
with delegated_child_context("child-A"):
|
|
self._run_as("conv-a", "sibling_secret = 'A'", task_id="t")
|
|
with delegated_child_context("child-B"):
|
|
peek = self._run_as(
|
|
"conv-a",
|
|
"print(globals().get('sibling_secret', 'ISOLATED'))",
|
|
task_id="t",
|
|
)
|
|
self.assertIn("ISOLATED", peek.get("output", ""), peek)
|
|
|
|
def test_live_children_keep_their_kernels_past_the_lru_cap(self):
|
|
"""A fan-out wider than max_session_kernels used to evict LIVE children's kernels (each
|
|
child's execute_code spawned a kernel, the cap reaped the oldest sibling's), so a child's
|
|
second call hit NameError on state its first call had set — 48 NameErrors across 28 lanes,
|
|
while the schema promised persistence. A live child's kernel is pinned for the child's life."""
|
|
import contextvars
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
|
|
with _kernel_config(max_session_kernels=2):
|
|
contexts = []
|
|
for index in range(5):
|
|
def _set(index=index):
|
|
with delegated_child_context(f"child-{index}"):
|
|
self._run_as("conv", f"v = {index}", task_id=f"child-{index}")
|
|
ctx = contextvars.copy_context()
|
|
ctx.run(_set)
|
|
contexts.append(ctx)
|
|
outcomes = {}
|
|
for index, ctx in enumerate(contexts):
|
|
def _read(index=index):
|
|
with delegated_child_context(f"child-{index}"):
|
|
outcomes[index] = self._run_as("conv", "print(v)", task_id=f"child-{index}")
|
|
ctx.run(_read)
|
|
for index, outcome in outcomes.items():
|
|
self.assertEqual(outcome["status"], "success", outcome)
|
|
self.assertTrue(outcome["kernel"]["reused"], outcome)
|
|
self.assertIn(str(index), outcome["output"])
|
|
|
|
def test_finished_children_release_their_kernels(self):
|
|
"""The pin is not a leak: when the child is torn down (the delegate_task cleanup path calls
|
|
``shutdown_kernels_for_delegated_child``) its kernels die and stop counting."""
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.code_kernel import shutdown_kernels_for_delegated_child
|
|
|
|
with _kernel_config():
|
|
with delegated_child_context("child-done"):
|
|
self._run_as("conv", "v = 1", task_id="child-done")
|
|
with delegated_child_context("child-live"):
|
|
self._run_as("conv", "v = 2", task_id="child-live")
|
|
doomed = [k for k in _KERNELS.values() if k.owner.endswith("::child::child-done")]
|
|
self.assertEqual(len(doomed), 1)
|
|
shutdown_kernels_for_delegated_child("child-done")
|
|
self.assertEqual([k for k in _KERNELS.values() if k.owner.endswith("::child::child-done")], [])
|
|
doomed[0].proc.wait(timeout=10)
|
|
self.assertFalse(doomed[0].alive())
|
|
# The sibling's kernel is untouched.
|
|
with delegated_child_context("child-live"):
|
|
still = self._run_as("conv", "print(v)", task_id="child-live")
|
|
self.assertIn("2", still["output"])
|
|
|
|
def test_session_clear_disposes_the_owners_kernels(self):
|
|
from tools.approval import clear_session
|
|
|
|
with _kernel_config():
|
|
self._run_as("conv-a", "x = 41", task_id="turn-1")
|
|
self.assertEqual(len(_KERNELS), 1)
|
|
kernel = next(iter(_KERNELS.values()))
|
|
self.assertTrue(kernel.alive())
|
|
clear_session("conv-a")
|
|
self.assertEqual(len(_KERNELS), 0)
|
|
kernel.proc.wait(timeout=10)
|
|
self.assertFalse(kernel.alive())
|
|
# The next turn in a cleared session starts fresh.
|
|
after = self._run_as("conv-a", "print('x' in dir())", task_id="turn-2")
|
|
self.assertEqual(after["status"], "success", after)
|
|
self.assertIn("False", after["output"])
|
|
|
|
def test_live_kernels_are_capped_lru_across_owners(self):
|
|
with _kernel_config(max_session_kernels=2):
|
|
kernels = []
|
|
for index in range(4):
|
|
self._run_as(f"conv-{index}", "x = 1", task_id=f"turn-{index}")
|
|
kernels.append(list(_KERNELS.values()))
|
|
self.assertLessEqual(len(_KERNELS), 2)
|
|
live_owners = {key[0] for key in _KERNELS}
|
|
# The two most recently used owners survive.
|
|
self.assertEqual(live_owners, {"conv-2", "conv-3"})
|
|
# Evicted kernels are actually dead, not orphaned.
|
|
evicted = [
|
|
kernel
|
|
for snapshot in kernels
|
|
for kernel in snapshot
|
|
if kernel.key not in _KERNELS
|
|
]
|
|
for kernel in evicted:
|
|
kernel.proc.wait(timeout=10)
|
|
self.assertFalse(kernel.alive())
|
|
|
|
def test_idle_kernels_are_reaped(self):
|
|
import time as time_module
|
|
|
|
with _kernel_config(kernel_idle_timeout=1):
|
|
self._run_as("conv-a", "x = 41", task_id="turn-1")
|
|
stale = next(iter(_KERNELS.values()))
|
|
time_module.sleep(1.2)
|
|
# Any owner's next call sweeps expired kernels process-wide.
|
|
self._run_as("conv-b", "y = 1", task_id="turn-2")
|
|
self.assertNotIn(stale.key, _KERNELS)
|
|
stale.proc.wait(timeout=10)
|
|
self.assertFalse(stale.alive())
|
|
|
|
def test_parallel_cells_share_one_kernel_process(self):
|
|
"""Parallel cells for one owner race the first spawn. Each racer
|
|
used to see proc=None as 'dead', replace the registry entry, and
|
|
orphan the winner's process — 110 live kernels under a 4-capped
|
|
process (Sep 2026). Every kernel process must stay registry-owned.
|
|
|
|
Capture actual children so an unregistered spawn cannot hide behind
|
|
the registry count. The owned process must exit on teardown."""
|
|
import threading
|
|
|
|
spawned = []
|
|
real_popen = subprocess.Popen
|
|
|
|
def _capturing_popen(args, **kwargs):
|
|
proc = real_popen(args, **kwargs)
|
|
spawned.append((proc, list(args)))
|
|
return proc
|
|
|
|
results = []
|
|
with patch("tools.code_kernel.subprocess.Popen", side_effect=_capturing_popen):
|
|
with _kernel_config():
|
|
def _cell():
|
|
results.append(self._run_as("conv-a", "import time; time.sleep(0.3)", task_id="t"))
|
|
threads = [threading.Thread(target=_cell) for _ in range(6)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
self.assertEqual([r["status"] for r in results], ["success"] * 6)
|
|
self.assertEqual(len(_KERNELS), 1)
|
|
runners = [proc for proc, args in spawned
|
|
if len(args) == 2 and Path(args[1]).name == "hermes_kernel_runner.py"]
|
|
self.assertEqual(len(runners), 1, "parallel cells spawned an unowned kernel")
|
|
kernel = next(iter(_KERNELS.values()))
|
|
self.assertIs(kernel.proc, runners[0])
|
|
self.assertIsNone(kernel.proc.poll())
|
|
shutdown_all_kernels()
|
|
for proc in runners:
|
|
proc.wait(timeout=10)
|
|
self.assertIsNotNone(proc.returncode)
|
|
|
|
|
|
class TestPerCellRpcAuthority(unittest.TestCase):
|
|
"""Interpreter state persists across cells; RPC authority must not."""
|
|
|
|
def _recorder(self, seen):
|
|
def _handle(tool_name, tool_args, task_id=None):
|
|
from tools.thread_context import _callback_api
|
|
|
|
(get_approval, _set_a), *_rest = _callback_api()
|
|
seen.append(
|
|
{
|
|
"tool": tool_name,
|
|
"task_id": task_id,
|
|
"approval_cb": get_approval(),
|
|
}
|
|
)
|
|
return json.dumps({"ok": True})
|
|
|
|
return _handle
|
|
|
|
def test_a_later_cells_rpc_runs_under_that_cells_authority(self):
|
|
from tools.terminal_tool import set_approval_callback
|
|
|
|
seen = []
|
|
cell = "import hermes_tools\nhermes_tools.web_search(query='q')\n"
|
|
with _kernel_config(), patch(
|
|
"model_tools.handle_function_call", new=self._recorder(seen)
|
|
):
|
|
def cb_one():
|
|
return "one"
|
|
|
|
def cb_two():
|
|
return "two"
|
|
|
|
set_approval_callback(cb_one)
|
|
try:
|
|
first = _run(cell)
|
|
set_approval_callback(cb_two)
|
|
second = _run(cell)
|
|
finally:
|
|
set_approval_callback(None)
|
|
self.assertEqual(first["status"], "success", first)
|
|
self.assertEqual(second["status"], "success", second)
|
|
self.assertEqual(len(seen), 2)
|
|
self.assertIs(seen[0]["approval_cb"], cb_one)
|
|
self.assertIs(seen[1]["approval_cb"], cb_two)
|
|
self.assertEqual(seen[0]["task_id"], "kernel-test")
|
|
|
|
def test_cross_cell_alias_dispatches_under_the_current_cell(self):
|
|
# Adversarial cross-cell dataflow: a callable captured in cell 1 and
|
|
# invoked by an opaque global name in cell 2 still crosses the RPC
|
|
# boundary — under cell 2's authority, allow-list, and budget — the
|
|
# operative enforcement a per-script static scan cannot provide once
|
|
# state persists (composition contract with the execute-code guard).
|
|
from tools.terminal_tool import set_approval_callback
|
|
|
|
seen = []
|
|
with _kernel_config(), patch(
|
|
"model_tools.handle_function_call", new=self._recorder(seen)
|
|
):
|
|
def cb_one():
|
|
return "one"
|
|
|
|
def cb_two():
|
|
return "two"
|
|
|
|
set_approval_callback(cb_one)
|
|
try:
|
|
first = _run("import hermes_tools\nalias = hermes_tools.web_search\n")
|
|
set_approval_callback(cb_two)
|
|
second = _run("alias(query='q')\n")
|
|
finally:
|
|
set_approval_callback(None)
|
|
self.assertEqual(first["status"], "success", first)
|
|
self.assertEqual(second["status"], "success", second)
|
|
self.assertEqual(len(seen), 1)
|
|
self.assertIs(seen[0]["approval_cb"], cb_two)
|
|
|
|
def test_a_settled_cells_authority_refuses_dispatch(self):
|
|
from tools.code_kernel import CellAuthority
|
|
|
|
authority = CellAuthority("turn-1")
|
|
authority.retire()
|
|
result = authority.dispatch("web_search", {"query": "q"})
|
|
self.assertIn("No active execute_code cell", result)
|
|
|
|
def test_each_cell_installs_a_fresh_authority(self):
|
|
with _kernel_config():
|
|
_run("x = 1")
|
|
kernel = next(iter(_KERNELS.values()))
|
|
first_authority = kernel.cell_authority
|
|
self.assertFalse(first_authority.active)
|
|
_run("y = 2")
|
|
self.assertIsNot(kernel.cell_authority, first_authority)
|
|
self.assertFalse(kernel.cell_authority.active)
|
|
|
|
|
|
class TestBackgroundIdleReaper(unittest.TestCase):
|
|
"""#117169: the idle sweep must not depend on the next kernel acquire — a host
|
|
that stays alive but wedged (e.g. pids exhaustion fail-closing every tool call)
|
|
never acquires again, so a background reaper reapplies the acquire-path criteria
|
|
on its own schedule, and staging dirs that outlived a dead host are swept by age."""
|
|
|
|
def _run_as(self, session_key, code, task_id, **kwargs):
|
|
from tools.approval_context import reset_current_session_key, set_current_session_key
|
|
|
|
token = set_current_session_key(session_key)
|
|
try:
|
|
return json.loads(execute_code(code, task_id=task_id, **kwargs))
|
|
finally:
|
|
reset_current_session_key(token)
|
|
|
|
def test_reap_once_sweeps_idle_kernels_without_a_new_acquire(self):
|
|
import time as time_module
|
|
|
|
from tools.code_kernel import _reap_once
|
|
|
|
with _kernel_config(kernel_idle_timeout=1):
|
|
self._run_as("conv-a", "x = 41", task_id="turn-1")
|
|
stale = next(iter(_KERNELS.values()))
|
|
time_module.sleep(1.2)
|
|
# No conv-b acquire here: the reaper pass alone must retire the kernel.
|
|
_reap_once()
|
|
self.assertNotIn(stale.key, _KERNELS)
|
|
stale.proc.wait(timeout=10)
|
|
self.assertFalse(stale.alive())
|
|
|
|
def test_reap_once_spares_attached_and_fresh_kernels(self):
|
|
from tools.code_kernel import _reap_once
|
|
|
|
with _kernel_config(kernel_idle_timeout=1):
|
|
fresh = self._run_as("conv-fresh", "x = 1", task_id="turn-1")
|
|
self.assertEqual(fresh["status"], "success", fresh)
|
|
kernel = next(iter(_KERNELS.values()))
|
|
kernel.attached += 1 # a cell is mid-flight: reaping must skip it
|
|
try:
|
|
_reap_once()
|
|
self.assertIn(kernel.key, _KERNELS)
|
|
self.assertTrue(kernel.alive())
|
|
finally:
|
|
kernel.attached -= 1
|
|
|
|
class TestStaleStagingDirSweep(unittest.TestCase):
|
|
def test_week_old_kernel_dirs_go_and_fresh_ones_stay(self):
|
|
import time as time_module
|
|
|
|
from tools.code_kernel import _sweep_stale_staging_dirs
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with patch("tools.code_kernel.tempfile.gettempdir", return_value=tmp):
|
|
old = Path(tmp, "hermes_kernel_old")
|
|
young = Path(tmp, "hermes_kernel_young")
|
|
bystander = Path(tmp, "unrelated_dir")
|
|
for path in (old, young, bystander):
|
|
path.mkdir()
|
|
week_and_a_bit = time_module.time() - 8 * 86400
|
|
os.utime(old, (week_and_a_bit, week_and_a_bit))
|
|
removed = _sweep_stale_staging_dirs()
|
|
# Asserted inside the TemporaryDirectory: cleanup would flatten everything.
|
|
self.assertEqual(removed, 1)
|
|
self.assertFalse(old.exists())
|
|
self.assertTrue(young.exists())
|
|
self.assertTrue(bystander.exists())
|