Files
ServerKit/backend/tests/test_plugin_agents_sdk.py
Juan DenisandClaude Opus 5 047e69501a feat(plugins): let extensions run commands on the fleet
Everything an extension could do stopped at the machine the panel runs on,
which is at odds with a product built around a fleet of servers. A plugin can
now run an agent command and get its result back.

This is also where agent.command:<action> starts meaning something. The
manifest permission has existed as a declaration — collected in the manifest,
surfaced at install time for consent — with nothing enforcing it, because
plugins called the agent registry directly. Dispatching through the SDK checks
it first, matched verbatim, so a plugin can only run the actions it asked the
operator for by name. The one bundled extension that uses agent commands still
calls the registry directly; retrofitting it onto this is a follow-up.

Nothing underneath is re-implemented: the per-server ACL still decides whether
that server permits the action, and the ServerCommand audit row is still
written — pass user_id so it names a person rather than "the panel".

Three things the wrapper adds over a bare send_command:

- it unwraps the double envelope. A transport-successful reply can carry a
  handler failure inside, which reads as success to anyone checking only the
  outer flag.
- it fails before dispatch on an offline agent or a missing capability, so the
  caller gets a reason instead of an unknown-action error from the far side.
- the default timeout is 45s rather than the registry's 30s. An agent on the
  HTTP long-poll fallback can sit ~25s before it even sees the command, which
  leaves a 30s budget almost no room to actually run anything.

Failures raise with a .code, so a plugin can tell an offline agent from a
refused one from a timeout, and map them to its own HTTP responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:02:08 -04:00

171 lines
7.3 KiB
Python

"""Running agent commands from a plugin (plugins_sdk.agents).
Two things this pins down:
* An extension can reach the fleet at all. Before it, everything a plugin could
do stopped at the machine the panel runs on.
* ``agent.command:<action>`` is now enforced. The manifest permission existed as
a declaration shown at install time, with nothing checking it — plugins called
the agent registry directly. Going through the SDK makes the consent dialog
mean what it says.
"""
import pytest
from app import db
from app.plugins_sdk import permissions
from app.plugins_sdk.agents_sdk import AgentCommandError, agents
from app.services import agent_registry as agent_registry_module
SERVER = 'server-uuid-1'
@pytest.fixture
def installed(app):
"""An installed plugin declaring one agent-command permission."""
from app.models.plugin import InstalledPlugin
plugin = InstalledPlugin(slug='serverkit-demo', name='serverkit-demo',
display_name='Demo', version='1.0.0',
status=InstalledPlugin.STATUS_ACTIVE)
plugin.manifest = {'permissions': ['agent.command:gui:screenshot']}
db.session.add(plugin)
db.session.commit()
return agents.for_plugin('serverkit-demo')
@pytest.fixture
def fleet(monkeypatch):
"""A connected agent whose responses each test can steer."""
registry = agent_registry_module.agent_registry
state = {'sent': [], 'reply': {'success': True, 'data': {'png': 'base64...'}},
'connected': True, 'capabilities': {'gui.screenshot': True}}
def _send(server_id, action, params=None, timeout=None, user_id=None):
state['sent'].append({'server_id': server_id, 'action': action,
'params': params, 'timeout': timeout,
'user_id': user_id})
return state['reply']
monkeypatch.setattr(registry, 'send_command', _send)
monkeypatch.setattr(registry, 'is_agent_connected',
lambda sid: state['connected'])
monkeypatch.setattr(registry, 'get_capabilities',
lambda sid: state['capabilities'])
monkeypatch.setattr(registry, 'has_capability',
lambda sid, cap: bool(state['capabilities'].get(cap)))
monkeypatch.setattr(registry, 'get_connected_servers',
lambda: [SERVER] if state['connected'] else [])
return state
class TestPermissionGate:
def test_a_declared_action_is_allowed(self, app, installed, fleet):
assert installed.run(SERVER, 'gui:screenshot') == {'png': 'base64...'}
def test_an_undeclared_action_is_refused_before_dispatch(self, app, installed, fleet):
with pytest.raises(permissions.PermissionDenied):
installed.run(SERVER, 'file:write', {'path': '/etc/passwd'})
# Refused, not merely reported: nothing reached the agent.
assert fleet['sent'] == []
def test_the_permission_is_matched_verbatim(self, app, installed, fleet):
# 'agent.command:gui:screenshot' must not also authorise 'gui:record'
# just because it shares a prefix.
with pytest.raises(permissions.PermissionDenied):
installed.run(SERVER, 'gui:record')
def test_an_uninstalled_plugin_has_no_permissions(self, app, fleet):
with pytest.raises(permissions.PermissionDenied):
agents.for_plugin('never-installed').run(SERVER, 'gui:screenshot')
class TestDispatch:
def test_passes_through_params_and_attribution(self, app, installed, fleet):
installed.run(SERVER, 'gui:screenshot', {'display': ':0'}, user_id=42)
sent = fleet['sent'][0]
assert sent['params'] == {'display': ':0'}
# user_id is what makes the ServerCommand audit row name a person.
assert sent['user_id'] == 42
def test_default_timeout_leaves_room_for_a_polling_agent(self, app, installed, fleet):
installed.run(SERVER, 'gui:screenshot')
# A poll-transport agent can sit ~25s before it sees the command.
assert fleet['sent'][0]['timeout'] >= 45.0
def test_offline_agent_is_refused_without_dispatching(self, app, installed, fleet):
fleet['connected'] = False
with pytest.raises(AgentCommandError) as excinfo:
installed.run(SERVER, 'gui:screenshot')
assert excinfo.value.code == 'AGENT_OFFLINE'
assert fleet['sent'] == []
def test_a_missing_capability_is_refused_with_a_reason(self, app, installed, fleet):
fleet['capabilities'] = {}
with pytest.raises(AgentCommandError) as excinfo:
installed.run(SERVER, 'gui:screenshot', requires='gui.screenshot')
# Better than an unknown-action error from the far side.
assert excinfo.value.code == 'UNSUPPORTED'
assert fleet['sent'] == []
def test_missing_arguments_are_refused(self, app, installed, fleet):
with pytest.raises(AgentCommandError):
installed.run('', 'gui:screenshot')
with pytest.raises(AgentCommandError):
installed.run(SERVER, '')
class TestFailures:
def test_a_failed_command_raises_with_its_code(self, app, installed, fleet):
fleet['reply'] = {'success': False, 'error': 'Command timeout',
'code': 'TIMEOUT'}
with pytest.raises(AgentCommandError) as excinfo:
installed.run(SERVER, 'gui:screenshot')
assert excinfo.value.code == 'TIMEOUT'
assert 'Command timeout' in str(excinfo.value)
def test_a_handler_failure_inside_a_successful_reply_still_raises(
self, app, installed, fleet):
# The double envelope: transport succeeded, the handler on the far side
# did not. Checking only the outer 'success' reads this as a win.
fleet['reply'] = {'success': True,
'data': {'success': False, 'error': 'no display'}}
with pytest.raises(AgentCommandError) as excinfo:
installed.run(SERVER, 'gui:screenshot')
assert 'no display' in str(excinfo.value)
def test_permission_denied_from_the_server_acl_surfaces(self, app, installed, fleet):
# The per-server ACL is a separate gate from the manifest permission,
# and the plugin should be able to tell them apart by code.
fleet['reply'] = {'success': False, 'error': 'Permission denied for action',
'code': 'PERMISSION_DENIED'}
with pytest.raises(AgentCommandError) as excinfo:
installed.run(SERVER, 'gui:screenshot')
assert excinfo.value.code == 'PERMISSION_DENIED'
def test_an_empty_reply_is_a_failure_not_a_success(self, app, installed, fleet):
fleet['reply'] = None
with pytest.raises(AgentCommandError):
installed.run(SERVER, 'gui:screenshot')
class TestReads:
def test_online_and_capabilities(self, app, installed, fleet):
assert installed.online() == [SERVER]
assert installed.is_online(SERVER) is True
assert installed.capabilities(SERVER) == {'gui.screenshot': True}
assert installed.has_capability(SERVER, 'gui.screenshot') is True
fleet['connected'] = False
assert installed.online() == []
assert installed.is_online(SERVER) is False
def test_sdk_is_reachable_from_the_package(app):
from app import plugins_sdk
assert plugins_sdk.agents.CommandError is AgentCommandError
with pytest.raises(ValueError):
plugins_sdk.agents.for_plugin('')