agentlock-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentlock/__init__.py +36 -0
- agentlock/_base.py +102 -0
- agentlock/browser.py +122 -0
- agentlock/client.py +74 -0
- agentlock/errors.py +38 -0
- agentlock/http.py +56 -0
- agentlock/mcp.py +94 -0
- agentlock/messaging.py +264 -0
- agentlock/signing.py +121 -0
- agentlock/ssh.py +260 -0
- agentlock_sdk-0.2.0.dist-info/METADATA +102 -0
- agentlock_sdk-0.2.0.dist-info/RECORD +13 -0
- agentlock_sdk-0.2.0.dist-info/WHEEL +4 -0
agentlock/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""AgentLock Python SDK"""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
AgentLockClient,
|
|
5
|
+
create_client,
|
|
6
|
+
GatewayResult,
|
|
7
|
+
ExecutionResult,
|
|
8
|
+
BrowserActionResult,
|
|
9
|
+
McpToolInfo,
|
|
10
|
+
McpListToolsResult,
|
|
11
|
+
McpContentItem,
|
|
12
|
+
McpCallToolResult,
|
|
13
|
+
AgentMessage,
|
|
14
|
+
NewThreadResult,
|
|
15
|
+
)
|
|
16
|
+
from .errors import (
|
|
17
|
+
AgentLockError,
|
|
18
|
+
ApprovalDeniedError,
|
|
19
|
+
ApprovalTimeoutError,
|
|
20
|
+
SshSessionExpiredError,
|
|
21
|
+
SshSessionClosedError,
|
|
22
|
+
)
|
|
23
|
+
from .signing import generate_keypair, KeyPair
|
|
24
|
+
from .ssh import SshSession, SshRunResult
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentLockClient", "create_client",
|
|
28
|
+
"AgentMessage", "BrowserActionResult", "ExecutionResult", "GatewayResult",
|
|
29
|
+
"McpCallToolResult", "McpContentItem", "McpListToolsResult", "McpToolInfo",
|
|
30
|
+
"NewThreadResult",
|
|
31
|
+
"SshSession", "SshRunResult",
|
|
32
|
+
"AgentLockError", "ApprovalDeniedError", "ApprovalTimeoutError",
|
|
33
|
+
"SshSessionExpiredError", "SshSessionClosedError",
|
|
34
|
+
"generate_keypair", "KeyPair",
|
|
35
|
+
]
|
|
36
|
+
__version__ = "0.2.0"
|
agentlock/_base.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Base mixin: HTTP transport, signing, request/await primitives, shared dataclasses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .signing import sign_request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GatewayResult:
|
|
15
|
+
request_id: str
|
|
16
|
+
decision: str
|
|
17
|
+
status: str
|
|
18
|
+
message: Optional[str] = None
|
|
19
|
+
expires_at: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ExecutionResult:
|
|
24
|
+
status: str
|
|
25
|
+
result: Optional[Dict[str, Any]] = None
|
|
26
|
+
error: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _BaseClient:
|
|
30
|
+
"""HTTP plumbing shared by all per-connector mixins.
|
|
31
|
+
|
|
32
|
+
Subclasses MUST set: ``base_url``, ``agent_id``, ``private_key``, ``timeout``,
|
|
33
|
+
``_client`` (an ``httpx.Client``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
base_url: str
|
|
37
|
+
agent_id: str
|
|
38
|
+
private_key: str
|
|
39
|
+
timeout: float
|
|
40
|
+
_client: httpx.Client
|
|
41
|
+
|
|
42
|
+
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
43
|
+
max_retries = 3
|
|
44
|
+
for attempt in range(max_retries + 1):
|
|
45
|
+
response = self._client.request(method, url, **kwargs)
|
|
46
|
+
if response.status_code not in (429, 502, 503, 504) or attempt == max_retries:
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
return response
|
|
49
|
+
retry_after = response.headers.get('retry-after')
|
|
50
|
+
delay = int(retry_after) if retry_after else min(2 ** attempt, 10)
|
|
51
|
+
time.sleep(delay)
|
|
52
|
+
raise RuntimeError('Unreachable')
|
|
53
|
+
|
|
54
|
+
def request_action(
|
|
55
|
+
self,
|
|
56
|
+
action_type: str,
|
|
57
|
+
tool: str,
|
|
58
|
+
payload: Dict[str, Any],
|
|
59
|
+
cost_estimate: Optional[float] = None,
|
|
60
|
+
idempotency_key: Optional[str] = None,
|
|
61
|
+
) -> GatewayResult:
|
|
62
|
+
body: Dict[str, Any] = {"action_type": action_type, "tool": tool, "payload": payload}
|
|
63
|
+
if cost_estimate is not None:
|
|
64
|
+
body["cost_estimate"] = cost_estimate
|
|
65
|
+
if idempotency_key:
|
|
66
|
+
body["idempotency_key"] = idempotency_key
|
|
67
|
+
|
|
68
|
+
headers = sign_request(body, self.agent_id, self.private_key)
|
|
69
|
+
headers["Content-Type"] = "application/json"
|
|
70
|
+
|
|
71
|
+
response = self._request_with_retry(
|
|
72
|
+
"POST", f"{self.base_url}/api/gateway/request", json=body, headers=headers,
|
|
73
|
+
)
|
|
74
|
+
data = response.json()
|
|
75
|
+
return GatewayResult(
|
|
76
|
+
request_id=data["request_id"],
|
|
77
|
+
decision=data["decision"],
|
|
78
|
+
status=data["status"],
|
|
79
|
+
message=data.get("message"),
|
|
80
|
+
expires_at=data.get("expires_at"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def await_result(
|
|
84
|
+
self,
|
|
85
|
+
request_id: str,
|
|
86
|
+
poll_interval: float = 2.0,
|
|
87
|
+
timeout: float = 300.0,
|
|
88
|
+
) -> ExecutionResult:
|
|
89
|
+
deadline = time.time() + timeout
|
|
90
|
+
while time.time() < deadline:
|
|
91
|
+
poll_body = {"requestId": request_id}
|
|
92
|
+
headers = sign_request(poll_body, self.agent_id, self.private_key)
|
|
93
|
+
response = self._request_with_retry(
|
|
94
|
+
"GET", f"{self.base_url}/api/gateway/result/{request_id}", headers=headers,
|
|
95
|
+
)
|
|
96
|
+
data = response.json()
|
|
97
|
+
if data["status"] in {"SUCCEEDED", "FAILED", "DENIED", "EXPIRED", "BLOCKED"}:
|
|
98
|
+
return ExecutionResult(
|
|
99
|
+
status=data["status"], result=data.get("result"), error=data.get("error"),
|
|
100
|
+
)
|
|
101
|
+
time.sleep(poll_interval)
|
|
102
|
+
raise TimeoutError(f"Timeout waiting for result of request {request_id}")
|
agentlock/browser.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Browser session mixin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .signing import sign_request
|
|
8
|
+
from ._base import _BaseClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BrowserActionResult:
|
|
13
|
+
session_id: str
|
|
14
|
+
snapshot: str
|
|
15
|
+
page_url: str
|
|
16
|
+
page_title: str
|
|
17
|
+
action_performed: str = ""
|
|
18
|
+
screenshot: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _BrowserMixin(_BaseClient):
|
|
22
|
+
def browser_open(
|
|
23
|
+
self,
|
|
24
|
+
url: str,
|
|
25
|
+
*,
|
|
26
|
+
allowed_domains: Optional[List[str]] = None,
|
|
27
|
+
) -> BrowserActionResult:
|
|
28
|
+
payload: Dict[str, Any] = {"url": url}
|
|
29
|
+
if allowed_domains is not None:
|
|
30
|
+
payload["allowed_domains"] = allowed_domains
|
|
31
|
+
|
|
32
|
+
gateway_result = self.request_action(
|
|
33
|
+
action_type="write", tool="browser.open", payload=payload,
|
|
34
|
+
)
|
|
35
|
+
if gateway_result.status == "BLOCKED":
|
|
36
|
+
raise RuntimeError(f"Browser open blocked: {gateway_result.message}")
|
|
37
|
+
|
|
38
|
+
final_result = self.await_result(gateway_result.request_id)
|
|
39
|
+
if final_result.status == "DENIED":
|
|
40
|
+
raise RuntimeError("Browser session was denied")
|
|
41
|
+
if final_result.status == "EXPIRED":
|
|
42
|
+
raise RuntimeError("Browser session approval expired")
|
|
43
|
+
if final_result.status == "FAILED":
|
|
44
|
+
raise RuntimeError(f"Browser open failed: {final_result.error}")
|
|
45
|
+
|
|
46
|
+
result = final_result.result or {}
|
|
47
|
+
if not result.get("session_id"):
|
|
48
|
+
raise RuntimeError("No session_id in browser.open result")
|
|
49
|
+
|
|
50
|
+
return BrowserActionResult(
|
|
51
|
+
session_id=result["session_id"],
|
|
52
|
+
snapshot=result.get("snapshot", ""),
|
|
53
|
+
page_url=result.get("page_url", ""),
|
|
54
|
+
page_title=result.get("page_title", ""),
|
|
55
|
+
action_performed=result.get("action_performed", ""),
|
|
56
|
+
screenshot=result.get("screenshot"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def browser_action(
|
|
60
|
+
self,
|
|
61
|
+
session_id: str,
|
|
62
|
+
tool: str,
|
|
63
|
+
params: Optional[Dict[str, Any]] = None,
|
|
64
|
+
) -> BrowserActionResult:
|
|
65
|
+
if not tool.startswith("browser."):
|
|
66
|
+
tool = f"browser.{tool}"
|
|
67
|
+
payload: Dict[str, Any] = {"session_id": session_id}
|
|
68
|
+
if params:
|
|
69
|
+
payload.update(params)
|
|
70
|
+
|
|
71
|
+
body: Dict[str, Any] = {"action_type": "write", "tool": tool, "payload": payload}
|
|
72
|
+
headers = sign_request(body, self.agent_id, self.private_key)
|
|
73
|
+
headers["Content-Type"] = "application/json"
|
|
74
|
+
|
|
75
|
+
response = self._client.post(
|
|
76
|
+
f"{self.base_url}/api/gateway/request", json=body, headers=headers,
|
|
77
|
+
)
|
|
78
|
+
if not response.is_success:
|
|
79
|
+
err = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
|
80
|
+
raise RuntimeError(f"Browser action failed: {err.get('error', response.text)}")
|
|
81
|
+
|
|
82
|
+
data = response.json()
|
|
83
|
+
result = data.get("result")
|
|
84
|
+
if not result:
|
|
85
|
+
raise RuntimeError(data.get("error", "No result from browser action"))
|
|
86
|
+
|
|
87
|
+
return BrowserActionResult(
|
|
88
|
+
session_id=result.get("session_id", session_id),
|
|
89
|
+
snapshot=result.get("snapshot", ""),
|
|
90
|
+
page_url=result.get("page_url", ""),
|
|
91
|
+
page_title=result.get("page_title", ""),
|
|
92
|
+
action_performed=result.get("action_performed", ""),
|
|
93
|
+
screenshot=result.get("screenshot"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def browser_fill_credentials(
|
|
97
|
+
self, session_id: str, credential_name: str, field_mapping: Dict[str, str],
|
|
98
|
+
) -> BrowserActionResult:
|
|
99
|
+
return self.browser_action(
|
|
100
|
+
session_id, "browser.fill_credentials",
|
|
101
|
+
params={"credential_name": credential_name, "field_mapping": field_mapping},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def browser_close(self, session_id: str) -> BrowserActionResult:
|
|
105
|
+
return self.browser_action(session_id, "browser.close")
|
|
106
|
+
|
|
107
|
+
def browser_snapshot(self, session_id: str) -> Dict[str, Any]:
|
|
108
|
+
"""Take an accessibility-tree snapshot of the current page.
|
|
109
|
+
|
|
110
|
+
Convenience wrapper over ``browser_action(session_id, 'snapshot')``.
|
|
111
|
+
Returns a dict with ``html``, ``url``, ``title``, and optionally
|
|
112
|
+
``screenshot`` (base64) when the runner returns one.
|
|
113
|
+
"""
|
|
114
|
+
r = self.browser_action(session_id, "browser.snapshot")
|
|
115
|
+
out: Dict[str, Any] = {
|
|
116
|
+
"html": r.snapshot,
|
|
117
|
+
"url": r.page_url,
|
|
118
|
+
"title": r.page_title,
|
|
119
|
+
}
|
|
120
|
+
if r.screenshot:
|
|
121
|
+
out["screenshot"] = r.screenshot
|
|
122
|
+
return out
|
agentlock/client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""AgentLock Python SDK Client (composition root).
|
|
2
|
+
|
|
3
|
+
The actual connector logic lives in per-mixin modules: _base, browser,
|
|
4
|
+
mcp, messaging. This file wires them together so the public
|
|
5
|
+
``AgentLockClient`` keeps its flat, instance-method-per-tool API.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._base import _BaseClient, GatewayResult, ExecutionResult
|
|
12
|
+
from .browser import _BrowserMixin, BrowserActionResult
|
|
13
|
+
from .http import _HttpMixin
|
|
14
|
+
from .mcp import _McpMixin, McpToolInfo, McpListToolsResult, McpContentItem, McpCallToolResult
|
|
15
|
+
from .messaging import _MessagingMixin, AgentMessage, NewThreadResult
|
|
16
|
+
from .ssh import _SshMixin, SshSession, SshRunResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentLockClient(
|
|
20
|
+
_SshMixin,
|
|
21
|
+
_BrowserMixin,
|
|
22
|
+
_McpMixin,
|
|
23
|
+
_MessagingMixin,
|
|
24
|
+
_HttpMixin,
|
|
25
|
+
_BaseClient,
|
|
26
|
+
):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
agent_id: str,
|
|
31
|
+
private_key: str,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
):
|
|
34
|
+
if not base_url.startswith("https://") and not base_url.startswith("http://localhost"):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"AgentLockClient: base_url must use HTTPS (or http://localhost for development)"
|
|
37
|
+
)
|
|
38
|
+
self.base_url = base_url.rstrip("/")
|
|
39
|
+
self.agent_id = agent_id
|
|
40
|
+
self.private_key = private_key
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self._client = httpx.Client(timeout=self.timeout)
|
|
43
|
+
|
|
44
|
+
def close(self):
|
|
45
|
+
self._client.close()
|
|
46
|
+
self.private_key = None # type: ignore[assignment]
|
|
47
|
+
|
|
48
|
+
def __enter__(self):
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *args):
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"AgentLockClient(agent_id={self.agent_id!r})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_client(
|
|
59
|
+
base_url: str, agent_id: str, private_key: str, timeout: float = 30.0,
|
|
60
|
+
) -> AgentLockClient:
|
|
61
|
+
return AgentLockClient(
|
|
62
|
+
base_url=base_url, agent_id=agent_id, private_key=private_key, timeout=timeout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Re-export public dataclasses so existing `from agentlock.client import X` works.
|
|
67
|
+
__all__ = [
|
|
68
|
+
"AgentLockClient", "create_client",
|
|
69
|
+
"GatewayResult", "ExecutionResult",
|
|
70
|
+
"BrowserActionResult",
|
|
71
|
+
"McpToolInfo", "McpListToolsResult", "McpContentItem", "McpCallToolResult",
|
|
72
|
+
"AgentMessage", "NewThreadResult",
|
|
73
|
+
"SshSession", "SshRunResult",
|
|
74
|
+
]
|
agentlock/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Typed error classes for the AgentLock SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgentLockError(Exception):
|
|
8
|
+
"""Base class for all AgentLock SDK errors."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApprovalDeniedError(AgentLockError):
|
|
12
|
+
def __init__(self, request_id: str, denial_reason: Optional[str] = None):
|
|
13
|
+
self.request_id = request_id
|
|
14
|
+
self.denial_reason = denial_reason
|
|
15
|
+
msg = f"Approval denied for request {request_id}"
|
|
16
|
+
if denial_reason:
|
|
17
|
+
msg += f": {denial_reason}"
|
|
18
|
+
super().__init__(msg)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApprovalTimeoutError(AgentLockError):
|
|
22
|
+
def __init__(self, request_id: str, waited_ms: int):
|
|
23
|
+
self.request_id = request_id
|
|
24
|
+
self.waited_ms = waited_ms
|
|
25
|
+
super().__init__(f"Approval timed out for request {request_id} after {waited_ms}ms")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SshSessionExpiredError(AgentLockError):
|
|
29
|
+
def __init__(self, session_id: str, expires_at: datetime):
|
|
30
|
+
self.session_id = session_id
|
|
31
|
+
self.expires_at = expires_at
|
|
32
|
+
super().__init__(f"SSH session {session_id} expired at {expires_at.isoformat()}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SshSessionClosedError(AgentLockError):
|
|
36
|
+
def __init__(self, session_id: str):
|
|
37
|
+
self.session_id = session_id
|
|
38
|
+
super().__init__(f"SSH session {session_id} has been closed")
|
agentlock/http.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""HTTP convenience wrapper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from ._base import _BaseClient
|
|
7
|
+
from .errors import ApprovalDeniedError, ApprovalTimeoutError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _HttpMixin(_BaseClient):
|
|
14
|
+
def http_request(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
method: str,
|
|
18
|
+
url: str,
|
|
19
|
+
headers: Optional[Dict[str, str]] = None,
|
|
20
|
+
body: Any = None,
|
|
21
|
+
credential_id: Optional[str] = None,
|
|
22
|
+
approval_timeout: float = 300.0,
|
|
23
|
+
) -> Dict[str, Any]:
|
|
24
|
+
"""Make an HTTP request through the AgentLock proxy.
|
|
25
|
+
|
|
26
|
+
Returns a dict with ``status``, ``headers``, ``body``, ``duration_ms``.
|
|
27
|
+
Raises ``ApprovalDeniedError`` / ``ApprovalTimeoutError`` if approval
|
|
28
|
+
is required and not granted.
|
|
29
|
+
"""
|
|
30
|
+
payload: Dict[str, Any] = {"method": method, "url": url}
|
|
31
|
+
if headers is not None: payload["headers"] = headers
|
|
32
|
+
if body is not None: payload["body"] = body
|
|
33
|
+
if credential_id is not None: payload["credential_id"] = credential_id
|
|
34
|
+
|
|
35
|
+
action_type = "read" if method.upper() in READ_ONLY_METHODS else "write"
|
|
36
|
+
gateway_result = self.request_action(
|
|
37
|
+
action_type=action_type, tool="http", payload=payload,
|
|
38
|
+
)
|
|
39
|
+
if gateway_result.status == "BLOCKED":
|
|
40
|
+
raise RuntimeError(f"http blocked: {gateway_result.message}")
|
|
41
|
+
|
|
42
|
+
final = self.await_result(gateway_result.request_id, timeout=approval_timeout)
|
|
43
|
+
if final.status == "DENIED":
|
|
44
|
+
raise ApprovalDeniedError(gateway_result.request_id, final.error)
|
|
45
|
+
if final.status == "EXPIRED":
|
|
46
|
+
raise ApprovalTimeoutError(gateway_result.request_id, int(approval_timeout * 1000))
|
|
47
|
+
if final.status == "FAILED":
|
|
48
|
+
raise RuntimeError(f"http failed: {final.error}")
|
|
49
|
+
|
|
50
|
+
r = final.result or {}
|
|
51
|
+
return {
|
|
52
|
+
"status": int(r.get("status", 0)),
|
|
53
|
+
"headers": r.get("headers", {}),
|
|
54
|
+
"body": r.get("body"),
|
|
55
|
+
"duration_ms": int(r.get("duration_ms", 0)),
|
|
56
|
+
}
|
agentlock/mcp.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) mixin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ._base import _BaseClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class McpToolInfo:
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
input_schema: Optional[Any] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class McpListToolsResult:
|
|
19
|
+
server: str
|
|
20
|
+
tools: List[McpToolInfo] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class McpContentItem:
|
|
25
|
+
type: str
|
|
26
|
+
text: Optional[str] = None
|
|
27
|
+
data: Optional[str] = None
|
|
28
|
+
mime_type: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class McpCallToolResult:
|
|
33
|
+
server: str
|
|
34
|
+
method: str
|
|
35
|
+
content: List[McpContentItem] = field(default_factory=list)
|
|
36
|
+
text: Optional[str] = None
|
|
37
|
+
is_error: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _McpMixin(_BaseClient):
|
|
41
|
+
def mcp_list_tools(self, server: str) -> McpListToolsResult:
|
|
42
|
+
gateway_result = self.request_action(
|
|
43
|
+
action_type="read", tool="mcp.list_tools", payload={"server": server},
|
|
44
|
+
)
|
|
45
|
+
if gateway_result.status == "BLOCKED":
|
|
46
|
+
raise RuntimeError(f"MCP list_tools blocked: {gateway_result.message}")
|
|
47
|
+
final_result = self.await_result(gateway_result.request_id)
|
|
48
|
+
if final_result.status == "FAILED":
|
|
49
|
+
raise RuntimeError(f"MCP list_tools failed: {final_result.error}")
|
|
50
|
+
|
|
51
|
+
result = final_result.result or {}
|
|
52
|
+
tools = [
|
|
53
|
+
McpToolInfo(
|
|
54
|
+
name=t.get("name", ""),
|
|
55
|
+
description=t.get("description", ""),
|
|
56
|
+
input_schema=t.get("input_schema"),
|
|
57
|
+
)
|
|
58
|
+
for t in result.get("tools", [])
|
|
59
|
+
]
|
|
60
|
+
return McpListToolsResult(server=result.get("server", server), tools=tools)
|
|
61
|
+
|
|
62
|
+
def mcp_call_tool(
|
|
63
|
+
self, server: str, method: str,
|
|
64
|
+
args: Optional[Dict[str, Any]] = None,
|
|
65
|
+
action_type: str = "write",
|
|
66
|
+
) -> McpCallToolResult:
|
|
67
|
+
gateway_result = self.request_action(
|
|
68
|
+
action_type=action_type, tool="mcp.call_tool",
|
|
69
|
+
payload={"server": server, "method": method, "arguments": args or {}},
|
|
70
|
+
)
|
|
71
|
+
if gateway_result.status == "BLOCKED":
|
|
72
|
+
raise RuntimeError(f"MCP call_tool blocked: {gateway_result.message}")
|
|
73
|
+
final_result = self.await_result(gateway_result.request_id)
|
|
74
|
+
if final_result.status == "DENIED":
|
|
75
|
+
raise RuntimeError("MCP call_tool was denied")
|
|
76
|
+
if final_result.status == "EXPIRED":
|
|
77
|
+
raise RuntimeError("MCP call_tool approval expired")
|
|
78
|
+
if final_result.status == "FAILED":
|
|
79
|
+
raise RuntimeError(f"MCP call_tool failed: {final_result.error}")
|
|
80
|
+
|
|
81
|
+
result = final_result.result or {}
|
|
82
|
+
content = [
|
|
83
|
+
McpContentItem(
|
|
84
|
+
type=c.get("type", ""), text=c.get("text"),
|
|
85
|
+
data=c.get("data"), mime_type=c.get("mimeType"),
|
|
86
|
+
)
|
|
87
|
+
for c in result.get("content", [])
|
|
88
|
+
]
|
|
89
|
+
return McpCallToolResult(
|
|
90
|
+
server=result.get("server", server),
|
|
91
|
+
method=result.get("method", method),
|
|
92
|
+
content=content, text=result.get("text"),
|
|
93
|
+
is_error=result.get("is_error", False),
|
|
94
|
+
)
|
agentlock/messaging.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""User<->agent messaging mixin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
from .signing import sign_request
|
|
10
|
+
from ._base import _BaseClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AgentMessage:
|
|
15
|
+
"""A message from the user→agent channel."""
|
|
16
|
+
id: str
|
|
17
|
+
thread_id: str
|
|
18
|
+
sender_type: str # "user" | "agent"
|
|
19
|
+
sender_id: str
|
|
20
|
+
content: str
|
|
21
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
read_at: Optional[str] = None
|
|
23
|
+
created_at: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class NewThreadResult:
|
|
28
|
+
"""Result from creating a new thread."""
|
|
29
|
+
thread_id: str
|
|
30
|
+
subject: Optional[str]
|
|
31
|
+
message: AgentMessage
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _MessagingMixin(_BaseClient):
|
|
35
|
+
def get_messages(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
since: Optional[str] = None,
|
|
39
|
+
limit: int = 20,
|
|
40
|
+
include_read: bool = False,
|
|
41
|
+
thread_id: Optional[str] = None,
|
|
42
|
+
) -> List[AgentMessage]:
|
|
43
|
+
"""Fetch messages addressed to this agent.
|
|
44
|
+
|
|
45
|
+
By default returns only unread ``user`` messages and marks them as
|
|
46
|
+
read server-side. Pass ``include_read=True`` to fetch the full
|
|
47
|
+
conversation history (both user and agent messages, read or unread).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
since: ISO-8601 timestamp; only return messages created after this.
|
|
51
|
+
limit: Max messages to fetch (default 20, clamped server-side to 50).
|
|
52
|
+
include_read: If True, include already-read messages and agent replies.
|
|
53
|
+
thread_id: Filter to a specific thread.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A list of ``AgentMessage`` objects, oldest first.
|
|
57
|
+
"""
|
|
58
|
+
params: Dict[str, str] = {}
|
|
59
|
+
if since is not None:
|
|
60
|
+
params["since"] = since
|
|
61
|
+
if limit:
|
|
62
|
+
params["limit"] = str(limit)
|
|
63
|
+
if include_read:
|
|
64
|
+
params["include_read"] = "true"
|
|
65
|
+
if thread_id:
|
|
66
|
+
params["thread_id"] = thread_id
|
|
67
|
+
|
|
68
|
+
# Sign the poll request — the gateway requires either a Bearer token
|
|
69
|
+
# or a valid Ed25519 signature. For GET the SDK signs a fixed body.
|
|
70
|
+
headers = sign_request({"_poll": "messages"}, self.agent_id, self.private_key)
|
|
71
|
+
|
|
72
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
73
|
+
response = self._request_with_retry(
|
|
74
|
+
"GET",
|
|
75
|
+
f"{self.base_url}/api/gateway/messages{query}",
|
|
76
|
+
headers=headers,
|
|
77
|
+
)
|
|
78
|
+
data = response.json()
|
|
79
|
+
messages = data.get("messages", []) if isinstance(data, dict) else []
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
AgentMessage(
|
|
83
|
+
id=m.get("id", ""),
|
|
84
|
+
thread_id=m.get("thread_id", ""),
|
|
85
|
+
sender_type=m.get("sender_type", ""),
|
|
86
|
+
sender_id=m.get("sender_id", ""),
|
|
87
|
+
content=m.get("content", ""),
|
|
88
|
+
metadata=m.get("metadata") or {},
|
|
89
|
+
read_at=m.get("read_at"),
|
|
90
|
+
created_at=m.get("created_at", ""),
|
|
91
|
+
)
|
|
92
|
+
for m in messages
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def send_message(
|
|
96
|
+
self,
|
|
97
|
+
thread_id: str,
|
|
98
|
+
content: str,
|
|
99
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
100
|
+
) -> AgentMessage:
|
|
101
|
+
"""Send a reply in an existing thread.
|
|
102
|
+
|
|
103
|
+
Use the ``thread_id`` from a received message to reply in context.
|
|
104
|
+
The message body is encrypted at rest by the gateway.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
thread_id: The thread to reply in.
|
|
108
|
+
content: The reply text (1–4096 chars).
|
|
109
|
+
metadata: Optional JSON metadata (≤8KB serialized).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The inserted ``AgentMessage``.
|
|
113
|
+
"""
|
|
114
|
+
body: Dict[str, Any] = {"thread_id": thread_id, "content": content}
|
|
115
|
+
if metadata is not None:
|
|
116
|
+
body["metadata"] = metadata
|
|
117
|
+
|
|
118
|
+
headers = sign_request(body, self.agent_id, self.private_key)
|
|
119
|
+
headers["Content-Type"] = "application/json"
|
|
120
|
+
|
|
121
|
+
response = self._request_with_retry(
|
|
122
|
+
"POST",
|
|
123
|
+
f"{self.base_url}/api/gateway/messages",
|
|
124
|
+
json=body,
|
|
125
|
+
headers=headers,
|
|
126
|
+
)
|
|
127
|
+
data = response.json()
|
|
128
|
+
msg = data.get("message", {}) if isinstance(data, dict) else {}
|
|
129
|
+
|
|
130
|
+
return AgentMessage(
|
|
131
|
+
id=msg.get("id", ""),
|
|
132
|
+
thread_id=msg.get("thread_id", thread_id),
|
|
133
|
+
sender_type=msg.get("sender_type", "agent"),
|
|
134
|
+
sender_id=msg.get("sender_id", self.agent_id),
|
|
135
|
+
content=msg.get("content", content),
|
|
136
|
+
metadata=msg.get("metadata") or {},
|
|
137
|
+
read_at=msg.get("read_at"),
|
|
138
|
+
created_at=msg.get("created_at", ""),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def create_thread(
|
|
142
|
+
self,
|
|
143
|
+
content: str,
|
|
144
|
+
*,
|
|
145
|
+
subject: Optional[str] = None,
|
|
146
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
147
|
+
) -> NewThreadResult:
|
|
148
|
+
"""Start a new thread with the user (agent-initiated).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
content: The first message body (1–4096 chars).
|
|
152
|
+
subject: Optional thread title (≤200 chars).
|
|
153
|
+
metadata: Optional JSON metadata (≤8KB serialized).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
``NewThreadResult`` with ``thread_id`` and the inserted message.
|
|
157
|
+
"""
|
|
158
|
+
body: Dict[str, Any] = {"create_thread": True, "content": content}
|
|
159
|
+
if subject is not None:
|
|
160
|
+
body["subject"] = subject
|
|
161
|
+
if metadata is not None:
|
|
162
|
+
body["metadata"] = metadata
|
|
163
|
+
|
|
164
|
+
headers = sign_request(body, self.agent_id, self.private_key)
|
|
165
|
+
headers["Content-Type"] = "application/json"
|
|
166
|
+
|
|
167
|
+
response = self._request_with_retry(
|
|
168
|
+
"POST",
|
|
169
|
+
f"{self.base_url}/api/gateway/messages",
|
|
170
|
+
json=body,
|
|
171
|
+
headers=headers,
|
|
172
|
+
)
|
|
173
|
+
data = response.json()
|
|
174
|
+
thread = data.get("thread", {}) if isinstance(data, dict) else {}
|
|
175
|
+
msg = data.get("message", {}) if isinstance(data, dict) else {}
|
|
176
|
+
|
|
177
|
+
return NewThreadResult(
|
|
178
|
+
thread_id=thread.get("id", ""),
|
|
179
|
+
subject=thread.get("subject"),
|
|
180
|
+
message=AgentMessage(
|
|
181
|
+
id=msg.get("id", ""),
|
|
182
|
+
thread_id=msg.get("thread_id", thread.get("id", "")),
|
|
183
|
+
sender_type=msg.get("sender_type", "agent"),
|
|
184
|
+
sender_id=msg.get("sender_id", self.agent_id),
|
|
185
|
+
content=msg.get("content", content),
|
|
186
|
+
metadata=msg.get("metadata") or {},
|
|
187
|
+
read_at=msg.get("read_at"),
|
|
188
|
+
created_at=msg.get("created_at", ""),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def on_message(
|
|
193
|
+
self,
|
|
194
|
+
callback: Callable[[AgentMessage], None],
|
|
195
|
+
*,
|
|
196
|
+
poll_interval: float = 5.0,
|
|
197
|
+
max_backoff: float = 60.0,
|
|
198
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
199
|
+
) -> Callable[[], None]:
|
|
200
|
+
"""Listen for messages in a background thread.
|
|
201
|
+
|
|
202
|
+
Polls ``GET /api/gateway/messages`` every ``poll_interval`` seconds and
|
|
203
|
+
calls ``callback`` once per new message. This is a polling transport —
|
|
204
|
+
expect up to one interval of latency between the user sending a message
|
|
205
|
+
and the agent receiving it. The gateway rate limit is 60 requests/min
|
|
206
|
+
per agent; do not set ``poll_interval`` below ~1 second.
|
|
207
|
+
|
|
208
|
+
Failures do not stop the loop: they are reported via ``on_error`` (if
|
|
209
|
+
provided) and the poller backs off exponentially up to ``max_backoff``
|
|
210
|
+
seconds. A successful poll resets the backoff.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
callback: Invoked for each new message.
|
|
214
|
+
poll_interval: Seconds between polls (default 5.0).
|
|
215
|
+
max_backoff: Max backoff on consecutive failures (default 60.0).
|
|
216
|
+
on_error: Optional callable invoked with any polling or callback error.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
A zero-arg ``stop()`` function — call it to stop the background
|
|
220
|
+
thread cleanly. The thread is a daemon so it will also die with
|
|
221
|
+
the interpreter.
|
|
222
|
+
"""
|
|
223
|
+
stop_event = threading.Event()
|
|
224
|
+
last_seen: Dict[str, Optional[str]] = {"ts": None}
|
|
225
|
+
|
|
226
|
+
def _loop() -> None:
|
|
227
|
+
consecutive_failures = 0
|
|
228
|
+
while not stop_event.is_set():
|
|
229
|
+
try:
|
|
230
|
+
messages = self.get_messages(since=last_seen["ts"])
|
|
231
|
+
consecutive_failures = 0
|
|
232
|
+
for msg in messages:
|
|
233
|
+
last_seen["ts"] = msg.created_at
|
|
234
|
+
try:
|
|
235
|
+
callback(msg)
|
|
236
|
+
except Exception as cb_err: # noqa: BLE001
|
|
237
|
+
if on_error is not None:
|
|
238
|
+
try:
|
|
239
|
+
on_error(cb_err)
|
|
240
|
+
except Exception: # noqa: BLE001
|
|
241
|
+
pass
|
|
242
|
+
# Success path: use the normal poll interval
|
|
243
|
+
if stop_event.wait(poll_interval):
|
|
244
|
+
return
|
|
245
|
+
except Exception as err: # noqa: BLE001
|
|
246
|
+
consecutive_failures += 1
|
|
247
|
+
if on_error is not None:
|
|
248
|
+
try:
|
|
249
|
+
on_error(err)
|
|
250
|
+
except Exception: # noqa: BLE001
|
|
251
|
+
pass
|
|
252
|
+
# Exponential backoff: poll_interval * 2^n, capped at max_backoff
|
|
253
|
+
exponent = min(consecutive_failures, 10)
|
|
254
|
+
backoff = min(poll_interval * (2 ** exponent), max_backoff)
|
|
255
|
+
if stop_event.wait(backoff):
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
thread = threading.Thread(target=_loop, name="agentlock-onmessage", daemon=True)
|
|
259
|
+
thread.start()
|
|
260
|
+
|
|
261
|
+
def stop() -> None:
|
|
262
|
+
stop_event.set()
|
|
263
|
+
|
|
264
|
+
return stop
|
agentlock/signing.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Ed25519 request signing for AgentLock Python SDK"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
# Signature scheme version. Bumped when the canonical pre-image format changes
|
|
12
|
+
# in a way that breaks backward compatibility. The gateway always accepts v1;
|
|
13
|
+
# when a future v2 is defined, clients bump this constant and send the header.
|
|
14
|
+
SIGNATURE_VERSION_CURRENT = "1"
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import nacl.signing
|
|
18
|
+
import nacl.encoding
|
|
19
|
+
HAS_NACL = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HAS_NACL = False
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
25
|
+
Ed25519PrivateKey,
|
|
26
|
+
Ed25519PublicKey,
|
|
27
|
+
)
|
|
28
|
+
HAS_CRYPTOGRAPHY = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
HAS_CRYPTOGRAPHY = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class KeyPair:
|
|
35
|
+
public_key: str # base64
|
|
36
|
+
private_key: str # base64
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_keypair() -> KeyPair:
|
|
40
|
+
"""Generate an Ed25519 keypair for agent authentication."""
|
|
41
|
+
if HAS_NACL:
|
|
42
|
+
signing_key = nacl.signing.SigningKey.generate()
|
|
43
|
+
verify_key = signing_key.verify_key
|
|
44
|
+
private_key_bytes = bytes(signing_key) + bytes(verify_key) # nacl concatenates
|
|
45
|
+
return KeyPair(
|
|
46
|
+
public_key=base64.b64encode(bytes(verify_key)).decode(),
|
|
47
|
+
private_key=base64.b64encode(private_key_bytes).decode(),
|
|
48
|
+
)
|
|
49
|
+
elif HAS_CRYPTOGRAPHY:
|
|
50
|
+
private_key = Ed25519PrivateKey.generate()
|
|
51
|
+
public_key = private_key.public_key()
|
|
52
|
+
private_bytes = private_key.private_bytes_raw()
|
|
53
|
+
public_bytes = public_key.public_bytes_raw()
|
|
54
|
+
combined = private_bytes + public_bytes # match nacl format
|
|
55
|
+
return KeyPair(
|
|
56
|
+
public_key=base64.b64encode(public_bytes).decode(),
|
|
57
|
+
private_key=base64.b64encode(combined).decode(),
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
raise ImportError("Install 'pynacl' or 'cryptography': pip install pynacl")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _CanonicalEncoder(json.JSONEncoder):
|
|
64
|
+
"""JSON encoder that normalizes floats with no fractional part to ints,
|
|
65
|
+
matching JavaScript's JSON.stringify behaviour (e.g. 1.0 -> 1)."""
|
|
66
|
+
|
|
67
|
+
def encode(self, o: Any) -> str:
|
|
68
|
+
return super().encode(self._normalize(o))
|
|
69
|
+
|
|
70
|
+
def _normalize(self, obj: Any) -> Any:
|
|
71
|
+
if isinstance(obj, float):
|
|
72
|
+
if math.isfinite(obj) and obj == int(obj):
|
|
73
|
+
return int(obj)
|
|
74
|
+
return obj
|
|
75
|
+
if isinstance(obj, dict):
|
|
76
|
+
return {k: self._normalize(v) for k, v in sorted(obj.items())}
|
|
77
|
+
if isinstance(obj, (list, tuple)):
|
|
78
|
+
return [self._normalize(v) for v in obj]
|
|
79
|
+
return obj
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def canonical_stringify(obj: dict) -> str:
|
|
83
|
+
"""Stable JSON serialization (sorted keys, JS-compatible float handling)."""
|
|
84
|
+
return json.dumps(obj, cls=_CanonicalEncoder, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sign_request(
|
|
88
|
+
body: dict,
|
|
89
|
+
agent_id: str,
|
|
90
|
+
private_key_b64: str,
|
|
91
|
+
) -> Dict[str, str]:
|
|
92
|
+
"""Sign a request body and return headers."""
|
|
93
|
+
timestamp = str(int(time.time() * 1000))
|
|
94
|
+
nonce = base64.b64encode(os.urandom(16)).decode()
|
|
95
|
+
canonical = canonical_stringify(body)
|
|
96
|
+
# Bind the signature-scheme version into the signed material so a future
|
|
97
|
+
# v2 rollout cannot be downgraded to v1 by stripping the header on the
|
|
98
|
+
# wire. Must mirror packages/shared/src/signing.ts:signRequest exactly.
|
|
99
|
+
message = f"{canonical}:{timestamp}:{nonce}:{SIGNATURE_VERSION_CURRENT}".encode()
|
|
100
|
+
|
|
101
|
+
private_key_bytes = base64.b64decode(private_key_b64)
|
|
102
|
+
|
|
103
|
+
if HAS_NACL:
|
|
104
|
+
# nacl format: 64 bytes (first 32 = private, last 32 = public)
|
|
105
|
+
signing_key = nacl.signing.SigningKey(private_key_bytes[:32])
|
|
106
|
+
signed = signing_key.sign(message)
|
|
107
|
+
signature = base64.b64encode(signed.signature).decode()
|
|
108
|
+
elif HAS_CRYPTOGRAPHY:
|
|
109
|
+
private_key = Ed25519PrivateKey.from_private_bytes(private_key_bytes[:32])
|
|
110
|
+
sig_bytes = private_key.sign(message)
|
|
111
|
+
signature = base64.b64encode(sig_bytes).decode()
|
|
112
|
+
else:
|
|
113
|
+
raise ImportError("Install 'pynacl' or 'cryptography'")
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"x-agent-id": agent_id,
|
|
117
|
+
"x-timestamp": timestamp,
|
|
118
|
+
"x-signature": signature,
|
|
119
|
+
"x-nonce": nonce,
|
|
120
|
+
"x-signature-version": SIGNATURE_VERSION_CURRENT,
|
|
121
|
+
}
|
agentlock/ssh.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""SSH session-handle pattern for the AgentLock SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional, Protocol
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .signing import sign_request
|
|
11
|
+
from ._base import _BaseClient
|
|
12
|
+
from .errors import (
|
|
13
|
+
AgentLockError,
|
|
14
|
+
ApprovalDeniedError,
|
|
15
|
+
ApprovalTimeoutError,
|
|
16
|
+
SshSessionClosedError,
|
|
17
|
+
SshSessionExpiredError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SshRunResult:
|
|
23
|
+
stdout: str
|
|
24
|
+
stderr: str
|
|
25
|
+
exit_code: Optional[int]
|
|
26
|
+
signal: Optional[str]
|
|
27
|
+
truncated: bool
|
|
28
|
+
duration_ms: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SshTransport(Protocol):
|
|
32
|
+
def run_on_session(self, session_id: str, command: str) -> SshRunResult: ...
|
|
33
|
+
def close_session(self, session_id: str) -> None: ...
|
|
34
|
+
def forget_session(self, session_id: str) -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SshSession:
|
|
39
|
+
id: str
|
|
40
|
+
host: str
|
|
41
|
+
user: str
|
|
42
|
+
port: int
|
|
43
|
+
via_vpn: bool
|
|
44
|
+
expires_at: dt.datetime
|
|
45
|
+
transport: SshTransport
|
|
46
|
+
last_activity_at: Optional[dt.datetime] = None
|
|
47
|
+
action_count: Optional[int] = None
|
|
48
|
+
_closed: bool = field(default=False, init=False, repr=False)
|
|
49
|
+
|
|
50
|
+
def is_expired(self) -> bool:
|
|
51
|
+
# Use timezone-aware UTC comparison
|
|
52
|
+
now = dt.datetime.now(self.expires_at.tzinfo or dt.timezone.utc)
|
|
53
|
+
return now >= self.expires_at
|
|
54
|
+
|
|
55
|
+
def run(self, command: str) -> SshRunResult:
|
|
56
|
+
if self._closed:
|
|
57
|
+
raise SshSessionClosedError(self.id)
|
|
58
|
+
if self.is_expired():
|
|
59
|
+
raise SshSessionExpiredError(self.id, self.expires_at)
|
|
60
|
+
return self.transport.run_on_session(self.id, command)
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
if self._closed:
|
|
64
|
+
return
|
|
65
|
+
self._closed = True
|
|
66
|
+
try:
|
|
67
|
+
self.transport.close_session(self.id)
|
|
68
|
+
finally:
|
|
69
|
+
self.transport.forget_session(self.id)
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> "SshSession":
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *args) -> None:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SshNamespace:
|
|
79
|
+
"""Owns the per-client session cache; implements SshTransport for handles."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, client: _BaseClient):
|
|
82
|
+
self._client = client
|
|
83
|
+
self._cache: Dict[str, SshSession] = {}
|
|
84
|
+
|
|
85
|
+
# ---- Public API -------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def open(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
credential_id: Optional[str] = None,
|
|
91
|
+
host: Optional[str] = None,
|
|
92
|
+
user: Optional[str] = None,
|
|
93
|
+
port: Optional[int] = None,
|
|
94
|
+
ttl_ms: Optional[int] = None,
|
|
95
|
+
approval_timeout: float = 300.0,
|
|
96
|
+
) -> SshSession:
|
|
97
|
+
payload: Dict[str, Any] = {}
|
|
98
|
+
if credential_id is not None:
|
|
99
|
+
payload["credential_id"] = credential_id
|
|
100
|
+
if host is not None:
|
|
101
|
+
payload["host"] = host
|
|
102
|
+
if user is not None:
|
|
103
|
+
payload["user"] = user
|
|
104
|
+
if port is not None:
|
|
105
|
+
payload["port"] = port
|
|
106
|
+
if ttl_ms is not None:
|
|
107
|
+
payload["ttl_ms"] = ttl_ms
|
|
108
|
+
|
|
109
|
+
gateway_result = self._client.request_action(
|
|
110
|
+
action_type="admin", tool="ssh.open", payload=payload,
|
|
111
|
+
)
|
|
112
|
+
if gateway_result.status == "BLOCKED":
|
|
113
|
+
raise RuntimeError(f"ssh.open blocked: {gateway_result.message}")
|
|
114
|
+
|
|
115
|
+
final = self._client.await_result(gateway_result.request_id, timeout=approval_timeout)
|
|
116
|
+
if final.status == "DENIED":
|
|
117
|
+
raise ApprovalDeniedError(gateway_result.request_id, final.error)
|
|
118
|
+
if final.status == "EXPIRED":
|
|
119
|
+
raise ApprovalTimeoutError(gateway_result.request_id, int(approval_timeout * 1000))
|
|
120
|
+
if final.status == "FAILED":
|
|
121
|
+
raise RuntimeError(f"ssh.open failed: {final.error}")
|
|
122
|
+
|
|
123
|
+
r = final.result or {}
|
|
124
|
+
session = SshSession(
|
|
125
|
+
id=str(r["session_id"]),
|
|
126
|
+
host=str(r.get("host", "")),
|
|
127
|
+
user=str(r.get("user", "")),
|
|
128
|
+
port=int(r.get("port", 22)),
|
|
129
|
+
via_vpn=bool(r.get("via_vpn", False)),
|
|
130
|
+
expires_at=dt.datetime.fromisoformat(str(r["expires_at"]).replace("Z", "+00:00")),
|
|
131
|
+
transport=self,
|
|
132
|
+
)
|
|
133
|
+
self._cache[session.id] = session
|
|
134
|
+
return session
|
|
135
|
+
|
|
136
|
+
def list(self) -> List[SshSession]:
|
|
137
|
+
headers = sign_request({}, self._client.agent_id, self._client.private_key)
|
|
138
|
+
try:
|
|
139
|
+
response = self._client._request_with_retry(
|
|
140
|
+
"GET", f"{self._client.base_url}/api/gateway/ssh-sessions", headers=headers,
|
|
141
|
+
)
|
|
142
|
+
except httpx.HTTPStatusError as e:
|
|
143
|
+
raise AgentLockError(
|
|
144
|
+
f"Failed to list SSH sessions (HTTP {e.response.status_code}): {e.response.text}"
|
|
145
|
+
) from e
|
|
146
|
+
data = response.json()
|
|
147
|
+
out: List[SshSession] = []
|
|
148
|
+
for s in data.get("sessions", []):
|
|
149
|
+
sid = s["session_id"]
|
|
150
|
+
fresh_host = s.get("host", "")
|
|
151
|
+
fresh_user = s.get("user", "")
|
|
152
|
+
fresh_port = int(s.get("port", 22))
|
|
153
|
+
fresh_via_vpn = bool(s.get("via_vpn", False))
|
|
154
|
+
fresh_expires_at = dt.datetime.fromisoformat(
|
|
155
|
+
str(s["expires_at"]).replace("Z", "+00:00")
|
|
156
|
+
)
|
|
157
|
+
fresh_last_activity = (
|
|
158
|
+
dt.datetime.fromisoformat(str(s["last_activity_at"]).replace("Z", "+00:00"))
|
|
159
|
+
if s.get("last_activity_at") else None
|
|
160
|
+
)
|
|
161
|
+
fresh_action_count = s.get("action_count")
|
|
162
|
+
|
|
163
|
+
existing = self._cache.get(sid)
|
|
164
|
+
if existing is not None:
|
|
165
|
+
# Refresh ALL wire-derived fields. Only `id` is the cache key
|
|
166
|
+
# and never changes. This matters for handles created via
|
|
167
|
+
# adopt() — they start as stubs with empty host/user and a
|
|
168
|
+
# placeholder expires_at, and list() is the canonical way to
|
|
169
|
+
# populate them.
|
|
170
|
+
existing.host = fresh_host
|
|
171
|
+
existing.user = fresh_user
|
|
172
|
+
existing.port = fresh_port
|
|
173
|
+
existing.via_vpn = fresh_via_vpn
|
|
174
|
+
existing.expires_at = fresh_expires_at
|
|
175
|
+
if fresh_last_activity is not None:
|
|
176
|
+
existing.last_activity_at = fresh_last_activity
|
|
177
|
+
if isinstance(fresh_action_count, int):
|
|
178
|
+
existing.action_count = fresh_action_count
|
|
179
|
+
out.append(existing)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
session = SshSession(
|
|
183
|
+
id=sid,
|
|
184
|
+
host=fresh_host,
|
|
185
|
+
user=fresh_user,
|
|
186
|
+
port=fresh_port,
|
|
187
|
+
via_vpn=fresh_via_vpn,
|
|
188
|
+
expires_at=fresh_expires_at,
|
|
189
|
+
last_activity_at=fresh_last_activity,
|
|
190
|
+
action_count=fresh_action_count,
|
|
191
|
+
transport=self,
|
|
192
|
+
)
|
|
193
|
+
self._cache[sid] = session
|
|
194
|
+
out.append(session)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def adopt(self, session_id: str) -> SshSession:
|
|
198
|
+
existing = self._cache.get(session_id)
|
|
199
|
+
if existing is not None:
|
|
200
|
+
return existing
|
|
201
|
+
stub = SshSession(
|
|
202
|
+
id=session_id, host="", user="", port=22, via_vpn=False,
|
|
203
|
+
expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=365),
|
|
204
|
+
transport=self,
|
|
205
|
+
)
|
|
206
|
+
self._cache[session_id] = stub
|
|
207
|
+
return stub
|
|
208
|
+
|
|
209
|
+
# ---- SshTransport implementation -------------------------------------
|
|
210
|
+
|
|
211
|
+
def run_on_session(self, session_id: str, command: str) -> SshRunResult:
|
|
212
|
+
gateway_result = self._client.request_action(
|
|
213
|
+
action_type="admin", tool="ssh.run",
|
|
214
|
+
payload={"session_id": session_id, "command": command},
|
|
215
|
+
)
|
|
216
|
+
if gateway_result.status == "BLOCKED":
|
|
217
|
+
raise RuntimeError(f"ssh.run blocked: {gateway_result.message}")
|
|
218
|
+
final = self._client.await_result(gateway_result.request_id)
|
|
219
|
+
if final.status == "DENIED":
|
|
220
|
+
raise ApprovalDeniedError(gateway_result.request_id, final.error)
|
|
221
|
+
if final.status == "EXPIRED":
|
|
222
|
+
raise ApprovalTimeoutError(gateway_result.request_id, 0)
|
|
223
|
+
if final.status == "FAILED":
|
|
224
|
+
raise RuntimeError(f"ssh.run failed: {final.error}")
|
|
225
|
+
r = final.result or {}
|
|
226
|
+
return SshRunResult(
|
|
227
|
+
stdout=str(r.get("stdout", "")),
|
|
228
|
+
stderr=str(r.get("stderr", "")),
|
|
229
|
+
exit_code=None if r.get("exit_code") is None else int(r["exit_code"]),
|
|
230
|
+
signal=None if r.get("signal") is None else str(r["signal"]),
|
|
231
|
+
truncated=bool(r.get("truncated", False)),
|
|
232
|
+
duration_ms=int(r.get("duration_ms", 0)),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def close_session(self, session_id: str) -> None:
|
|
236
|
+
try:
|
|
237
|
+
gateway_result = self._client.request_action(
|
|
238
|
+
action_type="admin", tool="ssh.close",
|
|
239
|
+
payload={"session_id": session_id},
|
|
240
|
+
)
|
|
241
|
+
if gateway_result.status == "BLOCKED":
|
|
242
|
+
return
|
|
243
|
+
self._client.await_result(gateway_result.request_id)
|
|
244
|
+
except Exception:
|
|
245
|
+
# close() must be idempotent
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def forget_session(self, session_id: str) -> None:
|
|
249
|
+
self._cache.pop(session_id, None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _SshMixin(_BaseClient):
|
|
253
|
+
"""Adds ``client.ssh`` namespace. Lazily constructed on first access."""
|
|
254
|
+
_ssh: Optional[SshNamespace] = None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def ssh(self) -> SshNamespace:
|
|
258
|
+
if self._ssh is None:
|
|
259
|
+
self._ssh = SshNamespace(self)
|
|
260
|
+
return self._ssh
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentlock-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AgentLock Python SDK for AI agent integration
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Provides-Extra: all
|
|
9
|
+
Requires-Dist: cryptography>=43.0.0; extra == 'all'
|
|
10
|
+
Requires-Dist: pynacl>=1.5.0; extra == 'all'
|
|
11
|
+
Provides-Extra: cryptography
|
|
12
|
+
Requires-Dist: cryptography>=43.0.0; extra == 'cryptography'
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: nacl
|
|
17
|
+
Requires-Dist: pynacl>=1.5.0; extra == 'nacl'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# agentlock-sdk (Python)
|
|
21
|
+
|
|
22
|
+
Python SDK for integrating AI agents with AgentLock.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install agentlock-sdk
|
|
28
|
+
# or with poetry:
|
|
29
|
+
poetry add agentlock-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from agentlock import AgentLockClient, generate_keypair
|
|
36
|
+
|
|
37
|
+
# Generate once
|
|
38
|
+
keypair = generate_keypair()
|
|
39
|
+
print("Public key (register in dashboard):", keypair.public_key)
|
|
40
|
+
|
|
41
|
+
# Create client
|
|
42
|
+
client = AgentLockClient(
|
|
43
|
+
base_url="https://your-agentlock.vercel.app",
|
|
44
|
+
agent_id="your-agent-id",
|
|
45
|
+
private_key=keypair.private_key, # or load from env
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Request an action
|
|
49
|
+
result = client.request_action(
|
|
50
|
+
action_type="write",
|
|
51
|
+
tool="demo",
|
|
52
|
+
payload={"message": "Hello from Python!"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
print(result.decision) # ALLOW | REQUIRE_APPROVAL | BLOCK
|
|
56
|
+
|
|
57
|
+
if result.status == "PENDING":
|
|
58
|
+
final = client.await_result(result.request_id)
|
|
59
|
+
print(final)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## SDK Implementation
|
|
63
|
+
|
|
64
|
+
See `src/agentlock/client.py` for the full implementation.
|
|
65
|
+
Uses `pynacl` for Ed25519 signing.
|
|
66
|
+
|
|
67
|
+
## SSH sessions
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
with client.ssh.open(credential_id="cred-1") as session:
|
|
71
|
+
print(session.run("uname -a").stdout)
|
|
72
|
+
print(session.run("uptime").stdout)
|
|
73
|
+
# auto-closes
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Re-attach to an existing session:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
sessions = client.ssh.list()
|
|
80
|
+
ops = next((s for s in sessions if s.host == "ops-prod-01"), None)
|
|
81
|
+
if ops:
|
|
82
|
+
print(ops.run("tail -n 50 /var/log/nginx/access.log").stdout)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Browser snapshot
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
snap = client.browser_snapshot(session_id)
|
|
89
|
+
# {"html": ..., "url": ..., "title": ..., "screenshot": ... (optional)}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## HTTP requests
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
res = client.http_request(
|
|
96
|
+
method="POST",
|
|
97
|
+
url="https://api.example.com/items",
|
|
98
|
+
body={"name": "x"},
|
|
99
|
+
credential_id="cred-api",
|
|
100
|
+
)
|
|
101
|
+
# {"status": 201, "headers": {...}, "body": {...}, "duration_ms": 42}
|
|
102
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
agentlock/__init__.py,sha256=eC_Q2BXxx7m6xBPZeMj6ToyDYg-NLtr3QHT92Q1b6xw,1007
|
|
2
|
+
agentlock/_base.py,sha256=oXFrvAU38MIk3evv78q7wOOQ07erdrqiogNw_roEADE,3525
|
|
3
|
+
agentlock/browser.py,sha256=AHP0fqJkJnG8qG-P4QyH9TpAdzNzGWh5XwcyVaCMyUQ,4637
|
|
4
|
+
agentlock/client.py,sha256=lqvC6KWkaCGBCKiTNY8c2TKFCTZpQ8qc6WI7rzNr0Ok,2362
|
|
5
|
+
agentlock/errors.py,sha256=-AhyQwaTjhY2inZ4zu9P940kj-2NBpRTayv2moj5v18,1361
|
|
6
|
+
agentlock/http.py,sha256=kDPZn6zZ6eHHKrK2mRXr6WwBP6_PuDfTnl2EfWRzNek,2143
|
|
7
|
+
agentlock/mcp.py,sha256=8-sgXRP1lV85uxMoONeP_1kPH__5hHs2mattMZHotWY,3235
|
|
8
|
+
agentlock/messaging.py,sha256=WNsdvXCrS7wE-11CTP6vHDTXj0tm9KlkXBmRwOW13n0,9964
|
|
9
|
+
agentlock/signing.py,sha256=Faxum7luIa5D7dPvdgdOjM-cPPELTmWf4DZQWp5u75k,4370
|
|
10
|
+
agentlock/ssh.py,sha256=KQwAGkK6pjugoBIU1T1ILo42PW-5NIKug4uu-OHfq-U,9636
|
|
11
|
+
agentlock_sdk-0.2.0.dist-info/METADATA,sha256=2lFHlQ-YXbT6WvwfnIB4a8xBJkGaAdJasqODVfBispk,2393
|
|
12
|
+
agentlock_sdk-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
agentlock_sdk-0.2.0.dist-info/RECORD,,
|