mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
1259 lines
43 KiB
Python
1259 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent import plugins as agent_plugins
|
|
from nanobot.agent.skills import SkillsLoader
|
|
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
|
|
|
|
|
|
def _write_cache(path: Path, registry: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps({"_cached_at": time.time(), "data": registry}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _manager(tmp_path: Path) -> CliAppManager:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
return CliAppManager(
|
|
workspace=workspace,
|
|
data_dir=tmp_path / "data",
|
|
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
|
|
)
|
|
|
|
|
|
def _seed_catalog(manager: CliAppManager) -> None:
|
|
harness = {
|
|
"meta": {"updated": "2026-04-16"},
|
|
"clis": [
|
|
{
|
|
"name": "gimp",
|
|
"display_name": "GIMP",
|
|
"version": "1.0.0",
|
|
"description": "Image editing",
|
|
"category": "image",
|
|
"requires": "Python 3.10+",
|
|
"install_cmd": "pip install cli-anything-gimp",
|
|
"entry_point": "cli-anything-gimp",
|
|
"skill_md": "skills/cli-anything-gimp/SKILL.md",
|
|
}
|
|
],
|
|
}
|
|
public = {
|
|
"meta": {"updated": "2026-04-18"},
|
|
"clis": [
|
|
{
|
|
"name": "gimp",
|
|
"display_name": "GIMP",
|
|
"description": "Public duplicate entry",
|
|
},
|
|
{
|
|
"name": "jimeng",
|
|
"display_name": "Jimeng",
|
|
"version": "latest",
|
|
"description": "Script install",
|
|
"category": "ai",
|
|
"install_strategy": "script",
|
|
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
|
|
"entry_point": "dreamina",
|
|
},
|
|
{
|
|
"name": "feishu",
|
|
"display_name": "Feishu/Lark CLI",
|
|
"version": "latest",
|
|
"description": "Official Lark CLI",
|
|
"category": "communication",
|
|
"package_manager": "npm",
|
|
"npm_package": "@larksuite/cli",
|
|
"install_cmd": "npm install -g @larksuite/cli",
|
|
"entry_point": "lark-cli",
|
|
},
|
|
{
|
|
"name": "dify-workflow",
|
|
"display_name": "Dify Workflow",
|
|
"version": "latest",
|
|
"description": "Run Dify workflows",
|
|
"category": "ai",
|
|
"install_cmd": "pip install cli-anything-dify-workflow",
|
|
"entry_point": "cli-anything-dify-workflow",
|
|
},
|
|
{
|
|
"name": "shopify",
|
|
"display_name": "Shopify CLI",
|
|
"version": "latest",
|
|
"description": "Shopify",
|
|
"category": "web",
|
|
"package_manager": "npm",
|
|
"npm_package": "@shopify/cli",
|
|
"install_cmd": "npm install -g @shopify/cli",
|
|
"entry_point": "shopify",
|
|
},
|
|
{
|
|
"name": "clibrowser",
|
|
"display_name": "clibrowser",
|
|
"version": "latest",
|
|
"description": "Cargo install",
|
|
"category": "web",
|
|
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
|
|
"entry_point": "clibrowser",
|
|
},
|
|
{
|
|
"name": "suno",
|
|
"display_name": "Suno CLI",
|
|
"version": "latest",
|
|
"description": "python3 pip install",
|
|
"category": "music",
|
|
"package_manager": "pip",
|
|
"install_strategy": "command",
|
|
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
|
|
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
|
|
"entry_point": "suno",
|
|
},
|
|
],
|
|
}
|
|
_write_cache(manager._cache_path("harness"), harness)
|
|
_write_cache(manager._cache_path("public"), public)
|
|
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
|
|
|
|
|
|
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
|
|
payload = manager.payload()
|
|
|
|
assert payload["catalog_updated_at"] == "2026-04-18"
|
|
apps = {app["name"]: app for app in payload["apps"]}
|
|
assert set(apps) == {
|
|
"clibrowser",
|
|
"dify-workflow",
|
|
"feishu",
|
|
"gimp",
|
|
"jimeng",
|
|
"shopify",
|
|
"suno",
|
|
}
|
|
assert apps["gimp"]["install_supported"] is True
|
|
assert apps["gimp"]["source"] == "harness+public"
|
|
assert apps["gimp"]["description"] == "Public duplicate entry"
|
|
assert apps["feishu"]["description"] == "Lark CLI"
|
|
assert apps["feishu"]["manifest"]["description"] == "Lark CLI"
|
|
assert apps["clibrowser"]["install_supported"] is False
|
|
assert apps["jimeng"]["install_supported"] is False
|
|
assert apps["suno"]["install_supported"] is True
|
|
assert apps["gimp"]["logo_url"]
|
|
gimp_manifest = apps["gimp"]["manifest"]
|
|
assert gimp_manifest["schema"] == "agent-app.v1"
|
|
assert gimp_manifest["id"] == "gimp"
|
|
assert gimp_manifest["source"] == "cli-anything:harness+public"
|
|
assert gimp_manifest["capabilities"][0]["type"] == "cli"
|
|
assert gimp_manifest["capabilities"][0]["entry_point"] == "cli-anything-gimp"
|
|
assert gimp_manifest["install"]["verification"] == ["entry_point_available"]
|
|
assert "entry_point_absent" in gimp_manifest["remove"]["verification"]
|
|
assert gimp_manifest["trust"]["review_status"] == "catalog_entry"
|
|
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
|
|
assert apps["feishu"]["logo_url"] == (
|
|
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
|
|
)
|
|
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
|
|
assert apps["clibrowser"]["logo_url"] == (
|
|
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
|
|
)
|
|
|
|
|
|
def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
|
|
_write_cache(
|
|
manager._cache_path("public"),
|
|
{
|
|
"meta": {"updated": "2026-04-18"},
|
|
"clis": [
|
|
{
|
|
"name": "anygen",
|
|
"display_name": "AnyGen",
|
|
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
|
|
"category": "generation",
|
|
"install_cmd": "pip install cli-anything-anygen",
|
|
"entry_point": "cli-anything-anygen",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
|
|
|
|
payload = manager.payload()
|
|
|
|
app = payload["apps"][0]
|
|
assert app["name"] == "anygen"
|
|
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
|
|
|
|
|
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(
|
|
manager._cache_path("harness"),
|
|
{
|
|
"meta": {"updated": "2026-07-14"},
|
|
"clis": [
|
|
{
|
|
"name": "obsidian-agent-cli",
|
|
"display_name": "Obsidian CLI",
|
|
"description": "Obsidian automation",
|
|
"category": "productivity",
|
|
"entry_point": "obsidian-agent",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
_write_cache(manager._cache_path("public"), {"meta": {}, "clis": []})
|
|
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
|
|
|
|
app = manager.payload()["apps"][0]
|
|
|
|
assert app["brand_color"] == "#7C3AED"
|
|
assert app["logo_url"] == "https://cdn.simpleicons.org/obsidian/7C3AED"
|
|
|
|
|
|
def test_installed_payload_enriches_apps_from_cached_catalog(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({
|
|
"gimp": {
|
|
"entry_point": "installed-gimp",
|
|
"source": "harness",
|
|
"strategy": "pip",
|
|
}
|
|
})
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry_point: "/bin/installed-gimp" if entry_point == "installed-gimp" else None,
|
|
)
|
|
|
|
app = manager.installed_payload()["apps"][0]
|
|
|
|
assert app["name"] == "gimp"
|
|
assert app["entry_point"] == "installed-gimp"
|
|
assert app["source"] == "harness"
|
|
assert app["status"] == "installed"
|
|
assert app["display_name"] == "GIMP"
|
|
assert app["category"] == "image"
|
|
assert app["description"] == "Public duplicate entry"
|
|
assert app["brand_color"] == "#5C5543"
|
|
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
|
|
|
|
|
|
def test_payload_includes_nanobot_extension_registry(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
|
|
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
|
|
_write_cache(
|
|
manager._cache_path("extensions"),
|
|
{
|
|
"meta": {"updated": "2026-05-29"},
|
|
"clis": [
|
|
{
|
|
"name": "hyperframes",
|
|
"display_name": "HyperFrames",
|
|
"version": "latest",
|
|
"description": "HTML-to-MP4 motion graphics CLI",
|
|
"category": "video",
|
|
"package_manager": "npm",
|
|
"npm_package": "hyperframes",
|
|
"install_cmd": "npm install -g hyperframes",
|
|
"entry_point": "hyperframes",
|
|
"logo_url": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/assets/logo.png",
|
|
"brand_color": "#111827",
|
|
"skill_md": "skills/hyperframes/SKILL.md",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
payload = manager.payload()
|
|
|
|
assert payload["catalog_updated_at"] == "2026-05-29"
|
|
app = payload["apps"][0]
|
|
assert app["name"] == "hyperframes"
|
|
assert app["source"] == "extensions"
|
|
assert app["logo_url"] == "https://raw.githubusercontent.com/heygen-com/hyperframes/main/assets/logo.png"
|
|
assert app["brand_color"] == "#111827"
|
|
assert app["install_supported"] is True
|
|
assert app["manifest"]["source"] == "nanobot-extension"
|
|
assert app["manifest"]["trust"]["registry"] == "nanobot-extension"
|
|
|
|
|
|
def test_optional_extension_registry_failure_does_not_break_payload(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(
|
|
manager._cache_path("harness"),
|
|
{
|
|
"meta": {"updated": "2026-04-16"},
|
|
"clis": [
|
|
{
|
|
"name": "gimp",
|
|
"display_name": "GIMP",
|
|
"description": "Image editing",
|
|
"install_cmd": "pip install cli-anything-gimp",
|
|
"entry_point": "cli-anything-gimp",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
|
|
|
|
def fail_get(*args, **kwargs):
|
|
raise RuntimeError("network unavailable")
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
|
|
|
payload = manager.payload()
|
|
|
|
assert payload["catalog_updated_at"] == "2026-04-18"
|
|
assert [app["name"] for app in payload["apps"]] == ["gimp"]
|
|
|
|
|
|
def test_payload_cache_only_does_not_fetch_catalog(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
|
|
def fail_get(*args, **kwargs):
|
|
raise AssertionError("network should not be used")
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
|
|
|
payload = manager.payload(cache_only=True)
|
|
|
|
assert payload["catalog_updated_at"] == "2026-04-18"
|
|
assert {app["name"] for app in payload["apps"]} >= {"gimp", "feishu"}
|
|
assert manager.catalog_cache_fresh() is True
|
|
|
|
|
|
def test_catalog_cache_fresh_can_include_optional_sources(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(
|
|
manager._cache_path("harness"),
|
|
{"meta": {"updated": "2026-04-16"}, "clis": []},
|
|
)
|
|
_write_cache(
|
|
manager._cache_path("public"),
|
|
{"meta": {"updated": "2026-04-18"}, "clis": []},
|
|
)
|
|
|
|
assert manager.catalog_cache_fresh() is True
|
|
assert manager.catalog_cache_fresh(include_optional=True) is False
|
|
|
|
|
|
def test_payload_cache_only_without_cache_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
manager = _manager(tmp_path)
|
|
|
|
def fail_get(*args, **kwargs):
|
|
raise AssertionError("network should not be used")
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
|
|
|
payload = manager.payload(cache_only=True)
|
|
|
|
assert payload == {"apps": [], "installed_count": 0, "catalog_updated_at": None}
|
|
assert manager.catalog_cache_fresh() is False
|
|
|
|
|
|
def test_install_dispatches_safe_pip_and_installs_skill(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
calls.append(argv)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_fetch_skill_content",
|
|
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
|
)
|
|
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text("legacy", encoding="utf-8")
|
|
|
|
payload = manager.install("gimp")
|
|
|
|
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
|
assert payload["last_action"]["ok"] is True
|
|
assert payload["last_action"]["installed"] is True
|
|
assert "state_recorded" in payload["last_action"]["verification"]
|
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
|
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
|
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
|
assert skill.is_file()
|
|
manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8"))
|
|
assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0")
|
|
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
|
|
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
|
assert SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None
|
|
assert not legacy.exists()
|
|
|
|
|
|
def test_run_argv_logs_command_exit_and_output(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from nanobot.apps.cli import service as cli_service
|
|
|
|
manager = _manager(tmp_path)
|
|
records: list[str] = []
|
|
|
|
class _Logger:
|
|
def info(self, message: str, *args: object) -> None:
|
|
records.append(message.format(*args))
|
|
|
|
def fake_run(
|
|
argv: list[str],
|
|
*,
|
|
capture_output: bool,
|
|
text: bool,
|
|
encoding: str,
|
|
errors: str,
|
|
timeout: int,
|
|
env: dict[str, str],
|
|
) -> subprocess.CompletedProcess[str]:
|
|
assert capture_output is True
|
|
assert text is True
|
|
assert encoding == "utf-8"
|
|
assert errors == "replace"
|
|
assert timeout == 5
|
|
assert "OPENAI_API_KEY" not in env
|
|
assert env["PYTHONUNBUFFERED"] == "1"
|
|
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
|
|
|
|
monkeypatch.setattr(cli_service, "logger", _Logger())
|
|
monkeypatch.setattr(cli_service.subprocess, "run", fake_run)
|
|
|
|
result = manager._run_argv(["python", "-m", "pip", "install", "sample"], timeout=5)
|
|
|
|
assert result.returncode == 0
|
|
assert any(record.startswith("CLI Apps: running ") for record in records)
|
|
assert any("command exited with code 0" in record for record in records)
|
|
assert any("installed ok" in record for record in records)
|
|
|
|
|
|
def test_run_argv_decodes_utf8_output(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
|
|
result = manager._run_argv(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import sys; sys.stdout.buffer.write(chr(0x2713).encode('utf-8'))",
|
|
],
|
|
timeout=5,
|
|
)
|
|
|
|
assert result.returncode == 0
|
|
assert result.stdout == "\u2713"
|
|
|
|
|
|
def test_install_records_available_cli_without_reinstalling(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
resolved = tmp_path / "bin" / "lark-cli"
|
|
resolved.parent.mkdir()
|
|
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
|
|
def fail_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
raise AssertionError(f"unexpected install command: {argv}")
|
|
|
|
monkeypatch.setattr(manager, "_run_argv", fail_run)
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: str(resolved) if command == "lark-cli" else None,
|
|
)
|
|
|
|
payload = manager.install("feishu")
|
|
|
|
assert payload["last_action"]["ok"] is True
|
|
assert payload["last_action"]["installed"] is True
|
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
|
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
|
assert skill.is_file()
|
|
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_install_recovers_stale_npm_global_directory(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
|
|
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
|
|
_write_cache(
|
|
manager._cache_path("extensions"),
|
|
{
|
|
"meta": {"updated": "2026-05-29"},
|
|
"clis": [
|
|
{
|
|
"name": "hyperframes",
|
|
"display_name": "HyperFrames",
|
|
"package_manager": "npm",
|
|
"npm_package": "hyperframes",
|
|
"install_cmd": "npm install -g hyperframes",
|
|
"entry_point": "hyperframes",
|
|
"skill_md": "skills/hyperframes/SKILL.md",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
npm = str(tmp_path / "bin" / "npm")
|
|
global_root = tmp_path / "global"
|
|
stale_package = global_root / "hyperframes"
|
|
stale_temp = global_root / ".hyperframes-broken"
|
|
stale_package.mkdir(parents=True)
|
|
stale_temp.mkdir()
|
|
install_attempts = 0
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
nonlocal install_attempts
|
|
if argv == [npm, "root", "-g"]:
|
|
return subprocess.CompletedProcess(argv, 0, stdout=str(global_root), stderr="")
|
|
if argv == [npm, "install", "-g", "hyperframes"]:
|
|
install_attempts += 1
|
|
if install_attempts == 1:
|
|
return subprocess.CompletedProcess(
|
|
argv,
|
|
1,
|
|
stdout="",
|
|
stderr="npm error ENOTEMPTY\nnpm error syscall rename",
|
|
)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
raise AssertionError(f"unexpected command: {argv}")
|
|
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: npm if command == "npm" else None,
|
|
)
|
|
|
|
payload = manager.install("hyperframes")
|
|
|
|
assert install_attempts == 2
|
|
assert not stale_package.exists()
|
|
assert not stale_temp.exists()
|
|
assert payload["last_action"]["ok"] is True
|
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert installed["hyperframes"]["strategy"] == "npm"
|
|
|
|
|
|
def test_install_records_entry_point_path_and_pip_distribution(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
resolved = tmp_path / "bin" / "cli-anything-gimp"
|
|
resolved.parent.mkdir()
|
|
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_run_argv",
|
|
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
|
|
)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_fetch_skill_content",
|
|
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: str(resolved) if command == "cli-anything-gimp" else None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.importlib_metadata.distributions",
|
|
lambda: [
|
|
SimpleNamespace(
|
|
entry_points=[
|
|
SimpleNamespace(group="console_scripts", name="cli-anything-gimp"),
|
|
],
|
|
metadata={"Name": "cli-anything-gimp"},
|
|
)
|
|
],
|
|
)
|
|
|
|
manager.install("gimp")
|
|
|
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert installed["gimp"]["entry_point_path"] == str(resolved)
|
|
assert installed["gimp"]["pip_distribution"] == "cli-anything-gimp"
|
|
assert installed["gimp"]["display_name"] == "GIMP"
|
|
assert installed["gimp"]["category"] == "image"
|
|
assert installed["gimp"]["description"] == "Public duplicate entry"
|
|
|
|
|
|
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
|
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
|
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
|
|
|
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert set(installed) == {"zoom"}
|
|
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
|
|
|
|
|
|
def test_fetch_skill_content_rejects_untrusted_urls(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
|
|
def fail_get(*args, **kwargs):
|
|
raise AssertionError("untrusted skill URL should not be fetched")
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
|
|
|
|
assert manager._fetch_skill_content({
|
|
"name": "evil",
|
|
"skill_md": "https://example.com/SKILL.md",
|
|
}) is None
|
|
assert manager._fetch_skill_content({
|
|
"name": "evil",
|
|
"skill_md": "skills/../evil/SKILL.md",
|
|
}) is None
|
|
|
|
|
|
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
seen: list[str] = []
|
|
|
|
class Response:
|
|
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
|
|
|
|
@staticmethod
|
|
def raise_for_status() -> None:
|
|
return None
|
|
|
|
def fake_get(url: str, **kwargs):
|
|
seen.append(url)
|
|
return Response()
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
|
|
|
|
content = manager._fetch_skill_content({
|
|
"name": "gimp",
|
|
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
|
|
})
|
|
|
|
assert content and "# Test" in content
|
|
assert seen == [
|
|
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
|
|
]
|
|
|
|
|
|
def test_fetch_skill_content_uses_extension_raw_base_for_relative_skills(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
seen: list[str] = []
|
|
|
|
class Response:
|
|
text = "---\nname: hyperframes\ndescription: HyperFrames\n---\n# HyperFrames\n"
|
|
|
|
@staticmethod
|
|
def raise_for_status() -> None:
|
|
return None
|
|
|
|
def fake_get(url: str, **kwargs):
|
|
seen.append(url)
|
|
return Response()
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
|
|
|
|
content = manager._fetch_skill_content({
|
|
"name": "hyperframes",
|
|
"skill_md": "skills/hyperframes/SKILL.md",
|
|
"_raw_base": "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main",
|
|
})
|
|
|
|
assert content and "# HyperFrames" in content
|
|
assert seen == [
|
|
"https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/skills/hyperframes/SKILL.md"
|
|
]
|
|
|
|
|
|
def test_uninstall_removes_installed_state_and_generated_skill(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
|
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
|
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_run_argv",
|
|
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
|
|
)
|
|
|
|
payload = manager.uninstall("gimp")
|
|
|
|
assert payload["last_action"]["ok"] is True
|
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
assert not plugin_dir.exists()
|
|
|
|
|
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"suno": {"entry_point": "suno"}})
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
calls.append(argv)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
|
|
|
|
payload = manager.uninstall("suno")
|
|
|
|
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
|
|
assert payload["last_action"]["ok"] is True
|
|
|
|
|
|
def test_uninstall_uses_recorded_pip_distribution(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({
|
|
"gimp": {
|
|
"entry_point": "cli-anything-gimp",
|
|
"pip_distribution": "actual-dist-name",
|
|
"entry_point_path": str(tmp_path / "bin" / "cli-anything-gimp"),
|
|
}
|
|
})
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
calls.append(argv)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
|
|
|
|
payload = manager.uninstall("gimp")
|
|
|
|
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "actual-dist-name"]]
|
|
assert payload["last_action"]["ok"] is True
|
|
assert payload["last_action"]["removed"] is True
|
|
assert "entry_point_absent" in payload["last_action"]["verification"]
|
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
|
|
|
|
def test_uninstall_keeps_state_when_entry_point_still_available(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_run_argv",
|
|
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
|
|
)
|
|
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: "/usr/local/bin/cli-anything-gimp" if command == "cli-anything-gimp" else None,
|
|
)
|
|
|
|
payload = manager.uninstall("gimp")
|
|
|
|
assert payload["last_action"]["ok"] is False
|
|
assert payload["last_action"]["removed"] is False
|
|
assert payload["last_action"]["still_available"] is True
|
|
assert payload["last_action"]["verification_failed"] == ["entry_point_absent"]
|
|
assert "kept it installed" in payload["last_action"]["message"]
|
|
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
|
|
|
|
def test_uninstall_keeps_state_when_recorded_entry_point_still_exists(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
resolved = tmp_path / "bin" / "cli-anything-gimp"
|
|
resolved.parent.mkdir()
|
|
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
manager._save_installed({
|
|
"gimp": {
|
|
"entry_point": "cli-anything-gimp",
|
|
"entry_point_path": str(resolved),
|
|
}
|
|
})
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_run_argv",
|
|
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
|
|
)
|
|
|
|
payload = manager.uninstall("gimp")
|
|
|
|
assert payload["last_action"]["ok"] is False
|
|
assert str(resolved) in payload["last_action"]["message"]
|
|
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
|
|
|
|
|
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
manager._save_installed(
|
|
{
|
|
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
|
|
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
|
|
}
|
|
)
|
|
|
|
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
|
|
|
|
assert mentions == [
|
|
{
|
|
"name": "zoom",
|
|
"entry_point": "cli-anything-zoom",
|
|
"source": "public",
|
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
|
"tool": "run_cli_app",
|
|
},
|
|
{
|
|
"name": "gimp",
|
|
"entry_point": "cli-anything-gimp",
|
|
"source": "harness",
|
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
|
"tool": "run_cli_app",
|
|
},
|
|
]
|
|
|
|
|
|
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
|
|
|
manager.remove_skill("unimol_tools")
|
|
|
|
assert not legacy.exists()
|
|
|
|
|
|
def test_migrated_cli_app_skill_keeps_legacy_identity_alias(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
data_dir = tmp_path / "data"
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.get_runtime_subdir",
|
|
lambda _name: data_dir,
|
|
)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
manager = CliAppManager(workspace=workspace)
|
|
manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}})
|
|
manager.install_skill({
|
|
"name": "unimol_tools",
|
|
"display_name": "Uni-Mol Tools",
|
|
"entry_point": "unimol-tools",
|
|
})
|
|
agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True)
|
|
|
|
loader = SkillsLoader(workspace)
|
|
assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [
|
|
"cli-app-unimol-tools"
|
|
]
|
|
assert loader.load_skill("cli-app-unimol_tools") is not None
|
|
|
|
disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"})
|
|
assert "cli-app-unimol-tools" not in {
|
|
skill["name"] for skill in disabled.list_skills(filter_unavailable=False)
|
|
}
|
|
|
|
|
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
|
|
with pytest.raises(CliAppError, match="not found"):
|
|
manager.install("missing")
|
|
|
|
with pytest.raises(CliAppError, match="unsupported"):
|
|
manager.install("jimeng")
|
|
|
|
|
|
def test_run_installed_cli_uses_argv_without_shell(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
|
)
|
|
|
|
class _Process:
|
|
returncode = 0
|
|
|
|
def communicate(self, *, timeout: int) -> tuple[str, str]:
|
|
assert timeout == 5
|
|
return "ARGS=['--json', 'project', 'list']", ""
|
|
|
|
def fake_popen(argv: list[str], **kwargs: object) -> _Process:
|
|
assert argv == [resolved, "--json", "project", "list"]
|
|
assert "shell" not in kwargs
|
|
assert kwargs["text"] is True
|
|
assert kwargs["encoding"] == "utf-8"
|
|
assert kwargs["errors"] == "replace"
|
|
return _Process()
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.Popen", fake_popen)
|
|
monkeypatch.setattr("nanobot.apps.cli.service._WindowsJob.create", lambda: None)
|
|
manager._save_installed(
|
|
{
|
|
"gimp": {
|
|
"version": "1.0.0",
|
|
"entry_point": "cli-anything-gimp",
|
|
"source": "harness",
|
|
"strategy": "pip",
|
|
}
|
|
}
|
|
)
|
|
|
|
result = manager.run("gimp", ["project", "list"], json_output=True)
|
|
|
|
assert "CLI app 'gimp' exited 0" in result
|
|
assert "['--json', 'project', 'list']" in result
|
|
|
|
|
|
def test_run_reports_created_artifacts(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
|
)
|
|
|
|
class _Process:
|
|
returncode = 0
|
|
|
|
def communicate(self, *, timeout: int) -> tuple[str, str]:
|
|
assert timeout == 5
|
|
return "done", ""
|
|
|
|
def fake_popen(argv: list[str], **kwargs: object) -> _Process:
|
|
assert argv == [resolved, "render"]
|
|
cwd = Path(str(kwargs["cwd"]))
|
|
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
|
return _Process()
|
|
|
|
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.Popen", fake_popen)
|
|
monkeypatch.setattr("nanobot.apps.cli.service._WindowsJob.create", lambda: None)
|
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
|
|
|
result = manager.run("gimp", ["render"])
|
|
|
|
assert "Artifacts created or updated:" in result
|
|
assert "diagram.png (previewable image" in result
|
|
assert "" in result
|
|
|
|
|
|
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
|
|
|
with pytest.raises(CliAppError, match="outside the configured workspace"):
|
|
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
|
|
|
|
|
|
def test_install_uses_uv_pip_when_pip_unavailable(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
calls.append(argv)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
|
|
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: "/usr/bin/uv" if command == "uv" else None,
|
|
)
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
monkeypatch.setattr(manager, "_fetch_skill_content", lambda app: None)
|
|
|
|
manager.install("gimp")
|
|
|
|
assert calls[0][:6] == [
|
|
"uv",
|
|
"pip",
|
|
"install",
|
|
"--python",
|
|
sys.executable,
|
|
"cli-anything-gimp",
|
|
]
|
|
|
|
|
|
def test_update_uses_uv_pip_reinstall_when_pip_unavailable(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: "/usr/bin/uv" if command == "uv" else None,
|
|
)
|
|
|
|
argv = manager._pip_install_argv(
|
|
{"name": "gimp", "install_cmd": "pip install cli-anything-gimp"},
|
|
update=True,
|
|
)
|
|
|
|
assert argv == [
|
|
"uv",
|
|
"pip",
|
|
"install",
|
|
"--python",
|
|
sys.executable,
|
|
"--upgrade",
|
|
"--reinstall",
|
|
"cli-anything-gimp",
|
|
]
|
|
|
|
|
|
def test_uninstall_uses_uv_pip_when_pip_unavailable(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"suno": {"entry_point": "suno"}})
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
calls.append(argv)
|
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
|
|
|
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda command: "/usr/bin/uv" if command == "uv" else None,
|
|
)
|
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
|
|
|
manager.uninstall("suno")
|
|
|
|
assert calls[0] == [
|
|
"uv",
|
|
"pip",
|
|
"uninstall",
|
|
"--python",
|
|
sys.executable,
|
|
"suno-cli",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_keeps_event_loop_responsive(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "python"}})
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: sys.executable if entry == "python" else None,
|
|
)
|
|
|
|
task = asyncio.create_task(
|
|
manager.run_async(
|
|
"gimp",
|
|
["-c", "import time; time.sleep(0.08); print('done')"],
|
|
)
|
|
)
|
|
for _ in range(3):
|
|
await asyncio.sleep(0.01)
|
|
assert not task.done()
|
|
|
|
result = await asyncio.wait_for(task, timeout=2)
|
|
assert "CLI app 'gimp' exited 0" in result
|
|
assert "done" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_cancellation_terminates_process_tree(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "python"}})
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: sys.executable if entry == "python" else None,
|
|
)
|
|
started = manager.workspace / "parent-started.txt"
|
|
orphan_marker = manager.workspace / "orphan-survived.txt"
|
|
child_code = (
|
|
"import time; from pathlib import Path; time.sleep(0.5); "
|
|
f"Path({str(orphan_marker)!r}).write_text('orphan', encoding='utf-8')"
|
|
)
|
|
parent_code = (
|
|
"import subprocess, sys, time; from pathlib import Path; "
|
|
f"subprocess.Popen([sys.executable, '-c', {child_code!r}]); "
|
|
f"Path({str(started)!r}).write_text('started', encoding='utf-8'); "
|
|
"time.sleep(30)"
|
|
)
|
|
task = asyncio.create_task(manager.run_async("gimp", ["-c", parent_code]))
|
|
try:
|
|
for _ in range(200):
|
|
if started.exists():
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert started.exists()
|
|
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, timeout=3)
|
|
await asyncio.sleep(0.8)
|
|
assert not orphan_marker.exists()
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def _parent_exits_with_pipe_holding_descendant(started: Path, marker: Path) -> str:
|
|
child_code = (
|
|
"import time; from pathlib import Path; time.sleep(1.5); "
|
|
f"Path({str(marker)!r}).write_text('survived', encoding='utf-8')"
|
|
)
|
|
return (
|
|
"import subprocess, sys, time; from pathlib import Path; "
|
|
"time.sleep(0.25); "
|
|
f"subprocess.Popen([sys.executable, '-c', {child_code!r}]); "
|
|
f"Path({str(started)!r}).write_text('spawned', encoding='utf-8')"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_timeout_kills_pipe_holding_descendant_after_parent_exit(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "python"}})
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: sys.executable if entry == "python" else None,
|
|
)
|
|
started = manager.workspace / "descendant-started.txt"
|
|
marker = manager.workspace / "descendant-survived.txt"
|
|
parent_code = _parent_exits_with_pipe_holding_descendant(started, marker)
|
|
|
|
before = time.monotonic()
|
|
result = await asyncio.wait_for(
|
|
manager.run_async("gimp", ["-c", parent_code], timeout=1),
|
|
timeout=4,
|
|
)
|
|
elapsed = time.monotonic() - before
|
|
|
|
assert started.exists()
|
|
assert result == "CLI app 'gimp' timed out after 1s"
|
|
assert elapsed < 4
|
|
await asyncio.sleep(0.9)
|
|
assert not marker.exists()
|
|
|
|
|
|
def test_run_timeout_kills_pipe_holding_descendant_after_parent_exit(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = _manager(tmp_path)
|
|
_seed_catalog(manager)
|
|
manager._save_installed({"gimp": {"entry_point": "python"}})
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: sys.executable if entry == "python" else None,
|
|
)
|
|
started = manager.workspace / "sync-descendant-started.txt"
|
|
marker = manager.workspace / "sync-descendant-survived.txt"
|
|
parent_code = _parent_exits_with_pipe_holding_descendant(started, marker)
|
|
|
|
before = time.monotonic()
|
|
result = manager.run("gimp", ["-c", parent_code], timeout=1)
|
|
elapsed = time.monotonic() - before
|
|
|
|
assert started.exists()
|
|
assert result == "CLI app 'gimp' timed out after 1s"
|
|
assert elapsed < 4
|
|
time.sleep(0.9)
|
|
assert not marker.exists()
|