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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any