agentlock-sdk 0.2.0__tar.gz
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_sdk-0.2.0/.gitignore +65 -0
- agentlock_sdk-0.2.0/PKG-INFO +102 -0
- agentlock_sdk-0.2.0/README.md +83 -0
- agentlock_sdk-0.2.0/pyproject.toml +23 -0
- agentlock_sdk-0.2.0/src/agentlock/__init__.py +36 -0
- agentlock_sdk-0.2.0/src/agentlock/_base.py +102 -0
- agentlock_sdk-0.2.0/src/agentlock/browser.py +122 -0
- agentlock_sdk-0.2.0/src/agentlock/client.py +74 -0
- agentlock_sdk-0.2.0/src/agentlock/errors.py +38 -0
- agentlock_sdk-0.2.0/src/agentlock/http.py +56 -0
- agentlock_sdk-0.2.0/src/agentlock/mcp.py +94 -0
- agentlock_sdk-0.2.0/src/agentlock/messaging.py +264 -0
- agentlock_sdk-0.2.0/src/agentlock/signing.py +121 -0
- agentlock_sdk-0.2.0/src/agentlock/ssh.py +260 -0
- agentlock_sdk-0.2.0/tests/__init__.py +0 -0
- agentlock_sdk-0.2.0/tests/test_browser_snapshot.py +36 -0
- agentlock_sdk-0.2.0/tests/test_http_request.py +45 -0
- agentlock_sdk-0.2.0/tests/test_signing.py +144 -0
- agentlock_sdk-0.2.0/tests/test_ssh.py +196 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
node_modules
|
|
2
|
+
.next
|
|
3
|
+
dist
|
|
4
|
+
.env
|
|
5
|
+
.env.local
|
|
6
|
+
.env.*.local
|
|
7
|
+
.turbo
|
|
8
|
+
.cache
|
|
9
|
+
.pnpm-store
|
|
10
|
+
*.tsbuildinfo
|
|
11
|
+
*.log
|
|
12
|
+
.DS_Store
|
|
13
|
+
.expo
|
|
14
|
+
android
|
|
15
|
+
ios
|
|
16
|
+
|
|
17
|
+
# Sensitive credential files — never commit
|
|
18
|
+
credentials.json
|
|
19
|
+
/credentials/
|
|
20
|
+
*.jks
|
|
21
|
+
*.keystore
|
|
22
|
+
*.pem
|
|
23
|
+
*.p8
|
|
24
|
+
*.p12
|
|
25
|
+
*.pfx
|
|
26
|
+
TESTDATEN.txt
|
|
27
|
+
|
|
28
|
+
# Claude Code local settings (may contain command history with secrets)
|
|
29
|
+
.claude/
|
|
30
|
+
|
|
31
|
+
# Playwright MCP screenshots (may contain sensitive UI state)
|
|
32
|
+
.playwright-mcp/
|
|
33
|
+
|
|
34
|
+
# Playwright test run artifacts
|
|
35
|
+
test-results/
|
|
36
|
+
playwright-report/
|
|
37
|
+
|
|
38
|
+
# Test scripts with hardcoded keys — never commit
|
|
39
|
+
test-flow.mjs
|
|
40
|
+
test-gateway.mjs
|
|
41
|
+
packages/shared/test-gateway.mjs
|
|
42
|
+
packages/shared/test-*.mjs
|
|
43
|
+
packages/shared/sign-*.mjs
|
|
44
|
+
packages/shared/e2e-ssh-test.mjs
|
|
45
|
+
scripts/e2e-ssh-test.mjs
|
|
46
|
+
|
|
47
|
+
# Throwaway working dir for E2E scratch files (signed requests, ed25519 keys)
|
|
48
|
+
tmp/
|
|
49
|
+
/tmp/
|
|
50
|
+
|
|
51
|
+
# superpowers brainstorm artifacts (server PIDs, HTML prototypes)
|
|
52
|
+
.superpowers/
|
|
53
|
+
|
|
54
|
+
# Python bytecode cache
|
|
55
|
+
__pycache__/
|
|
56
|
+
*.pyc
|
|
57
|
+
|
|
58
|
+
# Name-clash backup files (Windows duplicate-save format)
|
|
59
|
+
*# Name clash *#*
|
|
60
|
+
|
|
61
|
+
# Stray root-level test screenshots and placeholder configs
|
|
62
|
+
/test-*.png
|
|
63
|
+
/app.json
|
|
64
|
+
graphify-out/
|
|
65
|
+
.graphify_*
|
|
@@ -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,83 @@
|
|
|
1
|
+
# agentlock-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for integrating AI agents with AgentLock.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentlock-sdk
|
|
9
|
+
# or with poetry:
|
|
10
|
+
poetry add agentlock-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agentlock import AgentLockClient, generate_keypair
|
|
17
|
+
|
|
18
|
+
# Generate once
|
|
19
|
+
keypair = generate_keypair()
|
|
20
|
+
print("Public key (register in dashboard):", keypair.public_key)
|
|
21
|
+
|
|
22
|
+
# Create client
|
|
23
|
+
client = AgentLockClient(
|
|
24
|
+
base_url="https://your-agentlock.vercel.app",
|
|
25
|
+
agent_id="your-agent-id",
|
|
26
|
+
private_key=keypair.private_key, # or load from env
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Request an action
|
|
30
|
+
result = client.request_action(
|
|
31
|
+
action_type="write",
|
|
32
|
+
tool="demo",
|
|
33
|
+
payload={"message": "Hello from Python!"},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
print(result.decision) # ALLOW | REQUIRE_APPROVAL | BLOCK
|
|
37
|
+
|
|
38
|
+
if result.status == "PENDING":
|
|
39
|
+
final = client.await_result(result.request_id)
|
|
40
|
+
print(final)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## SDK Implementation
|
|
44
|
+
|
|
45
|
+
See `src/agentlock/client.py` for the full implementation.
|
|
46
|
+
Uses `pynacl` for Ed25519 signing.
|
|
47
|
+
|
|
48
|
+
## SSH sessions
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
with client.ssh.open(credential_id="cred-1") as session:
|
|
52
|
+
print(session.run("uname -a").stdout)
|
|
53
|
+
print(session.run("uptime").stdout)
|
|
54
|
+
# auto-closes
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Re-attach to an existing session:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
sessions = client.ssh.list()
|
|
61
|
+
ops = next((s for s in sessions if s.host == "ops-prod-01"), None)
|
|
62
|
+
if ops:
|
|
63
|
+
print(ops.run("tail -n 50 /var/log/nginx/access.log").stdout)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Browser snapshot
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
snap = client.browser_snapshot(session_id)
|
|
70
|
+
# {"html": ..., "url": ..., "title": ..., "screenshot": ... (optional)}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## HTTP requests
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
res = client.http_request(
|
|
77
|
+
method="POST",
|
|
78
|
+
url="https://api.example.com/items",
|
|
79
|
+
body={"name": "x"},
|
|
80
|
+
credential_id="cred-api",
|
|
81
|
+
)
|
|
82
|
+
# {"status": 201, "headers": {...}, "body": {...}, "duration_ms": 42}
|
|
83
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentlock-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "AgentLock Python SDK for AI agent integration"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
nacl = ["pynacl>=1.5.0"]
|
|
18
|
+
cryptography = ["cryptography>=43.0.0"]
|
|
19
|
+
all = ["pynacl>=1.5.0", "cryptography>=43.0.0"]
|
|
20
|
+
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/agentlock"]
|
|
@@ -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"
|
|
@@ -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}")
|
|
@@ -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
|
|
@@ -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
|
+
]
|
|
@@ -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")
|