Этап 8: настраиваемые команды
- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка; перечитывается автоматически, ошибки не ломают остальные команды - Действия run / open / http / keys; вывод и ответы возвращаются модели - Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами - Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам - Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat, переменные окружения раскрываются только в шаблоне - Вкладка «Команды» в настройках - Тесты разбора, действий, фраз и полных сценариев через Assistant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
84d8aaa848
commit
04029f3750
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agr_assistent.commands import actions
|
||||
from agr_assistent.commands.model import parse_command
|
||||
|
||||
|
||||
def _command(action: dict[str, Any], parameters: dict[str, Any] | None = None): # type: ignore[no-untyped-def]
|
||||
return parse_command(
|
||||
{"name": "test", "description": "Тест", "parameters": parameters, "action": action}
|
||||
)
|
||||
|
||||
|
||||
def test_substitution_encodes_urls_and_keeps_json_types() -> None:
|
||||
values = {"query": "кофе & чай", "level": 30, "on": True}
|
||||
|
||||
assert actions.substitute("q={query}", values, url_encode=True) == (
|
||||
"q=%D0%BA%D0%BE%D1%84%D0%B5%20%26%20%D1%87%D0%B0%D0%B9"
|
||||
)
|
||||
assert actions.substitute("{missing}-{level}", values) == "-30"
|
||||
assert actions.substitute_json(
|
||||
{"level": "{level}", "text": "уровень {level}", "flags": ["{on}"]}, values
|
||||
) == {"level": 30, "text": "уровень 30", "flags": [True]}
|
||||
|
||||
|
||||
def test_parameter_values_cannot_reach_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TEST_SECRET_TOKEN", "секрет")
|
||||
values = {"value": "${TEST_SECRET_TOKEN}"}
|
||||
|
||||
assert actions.substitute_json("Bearer ${TEST_SECRET_TOKEN} {value}", values) == (
|
||||
"Bearer секрет ${TEST_SECRET_TOKEN}"
|
||||
)
|
||||
|
||||
|
||||
def test_run_waits_and_returns_output() -> None:
|
||||
command = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": sys.executable,
|
||||
"args": ["-c", "import sys; print('привет', sys.argv[1])", "{name}"],
|
||||
"wait": True,
|
||||
},
|
||||
{"name": {"type": "string"}},
|
||||
)
|
||||
|
||||
outcome = actions.execute(command, {"name": "мир"})
|
||||
|
||||
assert outcome.ok
|
||||
assert "привет мир" in outcome.content
|
||||
|
||||
|
||||
def test_run_reports_exit_code_and_timeout() -> None:
|
||||
failing = _command(
|
||||
{"type": "run", "program": sys.executable, "args": ["-c", "raise SystemExit(3)"], "wait": True}
|
||||
)
|
||||
slow = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": sys.executable,
|
||||
"args": ["-c", "import time; time.sleep(5)"],
|
||||
"wait": True,
|
||||
"timeout_seconds": 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
failed = actions.execute(failing, {})
|
||||
assert not failed.ok and "Код завершения 3" in failed.content
|
||||
timed_out = actions.execute(slow, {})
|
||||
assert not timed_out.ok and "не завершилась" in timed_out.content
|
||||
assert not actions.execute(_command({"type": "run", "program": "no-such-program-xyz"}), {}).ok
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="bat-файлы есть только в Windows")
|
||||
def test_batch_file_rejects_cmd_metacharacters(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "ran.txt"
|
||||
script = tmp_path / "echo.bat"
|
||||
script.write_text(f'@echo %1 > "{marker}"\n', encoding="utf-8")
|
||||
command = _command(
|
||||
{"type": "run", "program": str(script), "args": ["{text}"], "wait": True},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
|
||||
refused = actions.execute(command, {"text": "hi & calc"})
|
||||
assert not refused.ok and "недопустимые символы" in refused.content
|
||||
assert not marker.exists()
|
||||
|
||||
assert actions.execute(command, {"text": "hello"}).ok
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="cmd и powershell есть только в Windows")
|
||||
def test_shell_interpreters_reject_injection_but_run_plain_values() -> None:
|
||||
cmd = _command(
|
||||
{"type": "run", "program": "cmd", "args": ["/c", "echo", "{text}"], "wait": True},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
powershell = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": "powershell",
|
||||
"args": ["-NoProfile", "-Command", "Write-Output '{text}'"],
|
||||
"wait": True,
|
||||
},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
|
||||
assert not actions.execute(cmd, {"text": "hi & calc"}).ok
|
||||
assert not actions.execute(powershell, {"text": "x'; Remove-Item C:\\temp"}).ok
|
||||
|
||||
echoed = actions.execute(cmd, {"text": "Привет"})
|
||||
assert echoed.ok and "Привет" in echoed.content # вывод cmd в OEM-кодировке декодируется
|
||||
written = actions.execute(powershell, {"text": "Мир"})
|
||||
assert written.ok and "Мир" in written.content
|
||||
|
||||
|
||||
def test_open_uses_encoded_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(actions, "open_target", opened.append)
|
||||
command = _command(
|
||||
{"type": "open", "target": "https://example.com/search?q={query}"},
|
||||
{"query": {"type": "string"}},
|
||||
)
|
||||
|
||||
assert actions.execute(command, {"query": "a b"}).ok
|
||||
assert opened == ["https://example.com/search?q=a%20b"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server() -> Iterator[tuple[str, list[dict[str, Any]]]]:
|
||||
received: list[dict[str, Any]] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def _respond(self) -> None:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
received.append(
|
||||
{
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"auth": self.headers.get("Authorization"),
|
||||
"body": json.loads(self.rfile.read(length)) if length else None,
|
||||
}
|
||||
)
|
||||
status = 500 if self.path.startswith("/fail") else 200
|
||||
payload = json.dumps({"state": "ok"}).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
do_GET = do_POST = _respond
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", received
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_http_sends_json_and_secret_headers(
|
||||
http_server: tuple[str, list[dict[str, Any]]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
base_url, received = http_server
|
||||
monkeypatch.setenv("TEST_HA_TOKEN", "token123")
|
||||
command = _command(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"url": base_url + "/light/turn_{state}",
|
||||
"headers": {"Authorization": "Bearer ${TEST_HA_TOKEN}"},
|
||||
"json": {"entity_id": "light.room", "brightness": "{level}"},
|
||||
},
|
||||
{"state": {"type": "string", "enum": ["on", "off"]}, "level": {"type": "integer"}},
|
||||
)
|
||||
|
||||
outcome = actions.execute(command, {"state": "on", "level": 80})
|
||||
|
||||
assert outcome.ok and "HTTP 200" in outcome.content
|
||||
assert received == [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/light/turn_on",
|
||||
"auth": "Bearer token123",
|
||||
"body": {"entity_id": "light.room", "brightness": 80},
|
||||
}
|
||||
]
|
||||
assert "token123" not in outcome.content + outcome.display
|
||||
|
||||
|
||||
def test_http_error_status_is_failure(http_server: tuple[str, list[dict[str, Any]]]) -> None:
|
||||
base_url, _received = http_server
|
||||
|
||||
outcome = actions.execute(_command({"type": "http", "url": base_url + "/fail"}), {})
|
||||
|
||||
assert not outcome.ok and "HTTP 500" in outcome.content
|
||||
assert not actions.execute(_command({"type": "http", "url": "http://127.0.0.1:9/"}), {}).ok
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="клавиши эмулируются только в Windows")
|
||||
def test_keys_repeat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pressed: list[list[int]] = []
|
||||
monkeypatch.setattr(actions, "send_keys", pressed.append)
|
||||
command = _command(
|
||||
{"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
|
||||
{"steps": {"type": "integer", "default": 3}},
|
||||
)
|
||||
|
||||
assert actions.execute(command, {}).ok
|
||||
assert pressed == [[0xAF]] * 3
|
||||
Reference in New Issue
Block a user