Add NE capability descriptors and native async exec jobs for agents.

Expose recommended_mode/hints on getManagedNe, support background exec-jobs with getNeExecJob in MCP, and allow linux_shell on MikroTik as well as Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-06 18:32:14 +08:00
parent c6a98e2526
commit 8bdaaaacbf
20 changed files with 928 additions and 88 deletions

View file

@ -0,0 +1,96 @@
"""Tests for NE capability descriptors and background exec jobs."""
from __future__ import annotations
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from netx_api.ne_capability import build_ne_capability
from netx_api.ne_exec_jobs import (
get_ne_exec_job,
should_run_exec_async,
start_ne_exec_job,
)
class NeCapabilityTests(unittest.TestCase):
def test_linux_shell_recommends_script(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(
device_type="linux",
exec_policy="linux_shell",
vendor="Other",
)
self.assertEqual(cap["device_family"], "linux")
self.assertEqual(cap["exec_policy_effective"], "linux_shell")
self.assertTrue(cap["allows_shell_scripts"])
self.assertTrue(cap["supports_async_job"])
self.assertEqual(cap["recommended_mode"], "script_on_device")
def test_mikrotik_shell_recommends_short_cli(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(
device_type="mikrotik_routeros",
exec_policy="linux_shell",
)
self.assertEqual(cap["device_family"], "mikrotik")
self.assertEqual(cap["recommended_mode"], "short_cli")
self.assertTrue(cap["allows_multiline"])
def test_readonly_network_cli(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(device_type="zte_zxros", exec_policy="readonly")
self.assertEqual(cap["device_family"], "network_cli")
self.assertEqual(cap["recommended_mode"], "show_only")
self.assertFalse(cap["allows_shell_scripts"])
def test_feature_off_forces_readonly_effective(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=False):
cap = build_ne_capability(device_type="linux", exec_policy="linux_shell")
self.assertEqual(cap["exec_policy_stored"], "linux_shell")
self.assertEqual(cap["exec_policy_effective"], "readonly")
self.assertFalse(cap["allows_shell_scripts"])
class NeExecJobTests(unittest.TestCase):
def test_should_run_async_flags(self) -> None:
self.assertTrue(should_run_exec_async({"async": True, "ne_id": "a"}))
self.assertFalse(should_run_exec_async({"async": False, "ne_ids": ["a", "b", "c", "d", "e"]}))
with patch("netx_api.ne_exec_jobs.async_min_ne_count", return_value=4):
self.assertTrue(
should_run_exec_async({"ne_ids": ["1", "2", "3", "4"]})
)
self.assertFalse(
should_run_exec_async({"ne_ids": ["1", "2"]})
)
def test_job_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with patch.dict("os.environ", {"NETX_NE_EXEC_JOB_DIR": tmp}):
ack = start_ne_exec_job(
kind="exec",
arguments={"ne_id": "abc", "commands": ["show version"]},
runner=lambda: {"ok": True, "output": "hi"},
)
self.assertTrue(ack.get("ok"))
jid = str(ack.get("job_id") or "")
self.assertTrue(jid)
done = None
for _ in range(50):
polled = get_ne_exec_job(jid)
if polled.get("terminal"):
done = polled
break
time.sleep(0.02)
self.assertIsNotNone(done)
assert done is not None
self.assertEqual(done.get("status"), "succeeded")
self.assertEqual((done.get("result") or {}).get("output"), "hi")
self.assertTrue(Path(tmp, f"{jid}.json").is_file())
if __name__ == "__main__":
unittest.main()

View file

@ -105,6 +105,10 @@ class NeExecValidationTests(unittest.TestCase):
"systemctl status sshd",
"cat /etc/os-release && uname -a",
"df -h; free -m",
"printf 'a\\nb' > /tmp/x",
"python3 -c \"print('hi')\"",
"cat <<'EOF' > /tmp/x\nhello world\nEOF",
"tee /tmp/x <<EOF\nline1\nline2\nEOF",
):
with self.subTest(cmd=cmd):
_validate_command(cmd, policy="linux_shell")
@ -117,13 +121,21 @@ class NeExecValidationTests(unittest.TestCase):
self.assertEqual(effective_exec_policy("linux_shell", device_type="linux"), "readonly")
self.assertEqual(effective_exec_policy("unrestricted", device_type="linux"), "readonly")
def test_effective_policy_forces_readonly_for_non_linux(self) -> None:
def test_effective_policy_forces_readonly_for_ineligible_types(self) -> None:
from netx_api.ne_exec_guard import effective_exec_policy
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
self.assertEqual(effective_exec_policy("linux_shell", device_type="zte_zxros"), "readonly")
self.assertEqual(effective_exec_policy("linux_shell", device_type="linux"), "linux_shell")
self.assertEqual(effective_exec_policy("unrestricted", device_type="linux_ssh"), "unrestricted")
self.assertEqual(
effective_exec_policy("linux_shell", device_type="mikrotik_routeros"),
"linux_shell",
)
self.assertEqual(
effective_exec_policy("unrestricted", device_type="mikrotik_switchos"),
"unrestricted",
)
def test_require_writable_rejects_when_feature_off(self) -> None:
from netx_api.ne_exec_guard import require_exec_policy_writable
@ -134,7 +146,7 @@ class NeExecValidationTests(unittest.TestCase):
require_exec_policy_writable("linux_shell", device_type="linux")
self.assertEqual(ctx.exception.detail, "exec_policy_feature_disabled")
def test_require_writable_rejects_non_linux(self) -> None:
def test_require_writable_rejects_ineligible_types(self) -> None:
from netx_api.ne_exec_guard import require_exec_policy_writable
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
@ -142,15 +154,37 @@ class NeExecValidationTests(unittest.TestCase):
require_exec_policy_writable("linux_shell", device_type="linux"),
"linux_shell",
)
self.assertEqual(
require_exec_policy_writable("linux_shell", device_type="mikrotik_routeros"),
"linux_shell",
)
with self.assertRaises(HTTPException) as ctx:
require_exec_policy_writable("linux_shell", device_type="cisco_ios")
self.assertEqual(ctx.exception.detail, "exec_policy_requires_linux_device_type")
self.assertEqual(ctx.exception.detail, "exec_policy_requires_shell_device_type")
def test_linux_shell_blocks_newline(self) -> None:
def test_linux_shell_allows_multiline_and_chained(self) -> None:
# Newlines are intentional for heredoc / small scripts (agent-friendly).
_validate_command("ls\nrm -rf /", policy="linux_shell")
_validate_command("cat <<'EOF' > /tmp/a\nx\nEOF", policy="unrestricted")
def test_linux_shell_blocks_unicode_line_separator(self) -> None:
with self.assertRaises(HTTPException) as ctx:
_validate_command("ls\nrm -rf /", policy="linux_shell")
_validate_command("ls\u2028rm -rf /", policy="linux_shell")
self.assertEqual(ctx.exception.detail, "command_chars_not_allowed")
def test_linux_shell_blocks_too_many_lines(self) -> None:
body = "\n".join(["echo x"] * 2001)
with self.assertRaises(HTTPException) as ctx:
_validate_command(body, policy="linux_shell")
self.assertEqual(ctx.exception.detail, "command_too_many_lines")
def test_linux_shell_allows_longer_than_readonly_cap(self) -> None:
cmd = "echo " + ("a" * 600)
with self.assertRaises(HTTPException) as ctx:
_validate_command(cmd, policy="readonly")
self.assertEqual(ctx.exception.detail, "command_too_long")
_validate_command(cmd, policy="linux_shell")
def test_readonly_still_blocks_linux_cmds(self) -> None:
with self.assertRaises(HTTPException) as ctx:
_validate_command("ls -la", policy="readonly")
@ -285,6 +319,31 @@ class NeExecRunTests(unittest.TestCase):
self.assertEqual(out["device"]["exec_policy"], "linux_shell")
collect.assert_called_once()
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
@patch("netx_api.ne_exec._collect_on_device", return_value="ros-ok")
@patch("netx_api.ne_exec.resolve_cli_target")
def test_execute_mikrotik_shell_policy_allows_multiline(self, resolve, collect, _configured) -> None:
resolve.return_value = (
_ready_creds(),
{
"source": "managed",
"id": "mt-1",
"exec_policy": "linux_shell",
"name": "rb5009",
"device_type": "mikrotik_routeros",
"ip_address": "10.0.0.10",
},
)
db = MagicMock()
script = ":local x 1\n:put $x\n/ip address print"
with patch("netx_api.config.settings") as mock_settings:
mock_settings.ne_exec_policy_enabled = True
out = execute_managed_ne_commands(db, [script], ne_id="mt-1")
self.assertTrue(out["ok"])
self.assertEqual(out["output"], "ros-ok")
self.assertEqual(out["device"]["exec_policy"], "linux_shell")
collect.assert_called_once()
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
@patch("netx_api.ne_exec.resolve_cli_target")

View file

@ -1,9 +1,9 @@
"""Tests for Netmiko show helpers (IOSv leftover-prompt drain / retry)."""
"""Tests for Netmiko show helpers (IOSv leftover-prompt drain; no re-send)."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
from netx_api.ne_netmiko import drain_read_channel, send_show_command
@ -24,25 +24,24 @@ class DrainReadChannelTests(unittest.TestCase):
class SendShowCommandTests(unittest.TestCase):
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_returns_first_nonempty_send_command(self, drain: MagicMock) -> None:
def test_drains_then_sends_once(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.send_command.return_value = "*12:00:00 UTC"
out = send_show_command(conn, "show clock", read_timeout=30)
self.assertEqual(out, "*12:00:00 UTC")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
drain.assert_called()
drain.assert_called_once_with(conn)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_retries_then_falls_back_to_timing_when_empty(self, drain: MagicMock) -> None:
def test_empty_output_does_not_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.send_command.return_value = ""
conn.send_command_timing.return_value = "Cisco IOS Software"
out = send_show_command(conn, "show version", read_timeout=30)
self.assertEqual(out, "Cisco IOS Software")
self.assertEqual(conn.send_command.call_count, 2)
conn.send_command_timing.assert_called_once_with("show version", read_timeout=30)
self.assertGreaterEqual(drain.call_count, 2)
self.assertEqual(out, "")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
drain.assert_called_once_with(conn)
if __name__ == "__main__":