portmark 0.1.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.
- portmark/__init__.py +7 -0
- portmark/a2a.py +140 -0
- portmark/a2a_types.py +238 -0
- portmark/cli.py +56 -0
- portmark/component_bindings.py +85 -0
- portmark/config.py +50 -0
- portmark/factory.py +92 -0
- portmark/host.py +252 -0
- portmark/logging_config.py +28 -0
- portmark/models.py +136 -0
- portmark/policy.py +127 -0
- portmark/providers.py +107 -0
- portmark/security.py +616 -0
- portmark/storage.py +326 -0
- portmark/tools.py +51 -0
- portmark/wasm_runner.mjs +61 -0
- portmark-0.1.0.dist-info/METADATA +9 -0
- portmark-0.1.0.dist-info/RECORD +22 -0
- portmark-0.1.0.dist-info/WHEEL +5 -0
- portmark-0.1.0.dist-info/entry_points.txt +2 -0
- portmark-0.1.0.dist-info/licenses/LICENSE +21 -0
- portmark-0.1.0.dist-info/top_level.txt +1 -0
portmark/__init__.py
ADDED
portmark/a2a.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .a2a_types import A2ARequestError, error_response, make_agent_card, parse_jsonrpc_request, success_response, task_from_run_result
|
|
12
|
+
from .host import AgentHost
|
|
13
|
+
from .models import AgentEnvelope, AgentManifest, AgentState, AttestationEvidence, Permit, ResourceBudget, ToolGrant
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
MAX_REQUEST_BYTES = 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def envelope_from_dict(value: dict[str, Any]) -> AgentEnvelope:
|
|
21
|
+
manifest = AgentManifest(**{**value["manifest"], "requested_tools": tuple(value["manifest"]["requested_tools"])})
|
|
22
|
+
permit_value = value["permit"]
|
|
23
|
+
permit = Permit(
|
|
24
|
+
issuer=permit_value["issuer"], subject=permit_value["subject"], audience=permit_value["audience"],
|
|
25
|
+
expires_at=permit_value["expires_at"], nonce=permit_value["nonce"],
|
|
26
|
+
grants=tuple(ToolGrant(**grant) for grant in permit_value["grants"]),
|
|
27
|
+
budget=ResourceBudget(**permit_value.get("budget", {})),
|
|
28
|
+
delegation_allowed=permit_value.get("delegation_allowed", False),
|
|
29
|
+
attestation=AttestationEvidence(**permit_value["attestation"]) if permit_value.get("attestation") else None,
|
|
30
|
+
)
|
|
31
|
+
return AgentEnvelope(
|
|
32
|
+
manifest=manifest,
|
|
33
|
+
permit=permit,
|
|
34
|
+
state=AgentState(**value["state"]),
|
|
35
|
+
previous_audit_hash=value.get("previous_audit_hash", ""),
|
|
36
|
+
signature_key_id=value.get("signature_key_id", ""),
|
|
37
|
+
signature=value["signature"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class A2AAuthConfig:
|
|
43
|
+
bearer_token: str | None = None
|
|
44
|
+
realm: str = "portmark"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def required(self) -> bool:
|
|
48
|
+
return bool(self.bearer_token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_handler(host: AgentHost, auth: A2AAuthConfig | None = None, enable_hsts: bool = False):
|
|
52
|
+
auth_config = auth or A2AAuthConfig()
|
|
53
|
+
|
|
54
|
+
class A2AHandler(BaseHTTPRequestHandler):
|
|
55
|
+
server_version = "PortableAgentA2A/1.0"
|
|
56
|
+
|
|
57
|
+
def _json(self, status: int, value: Any, headers: dict[str, str] | None = None) -> None:
|
|
58
|
+
payload = json.dumps(value).encode()
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
62
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
63
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
64
|
+
self.send_header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
65
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
66
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'")
|
|
67
|
+
if enable_hsts:
|
|
68
|
+
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
|
69
|
+
if headers:
|
|
70
|
+
for key, value in headers.items():
|
|
71
|
+
self.send_header(key, value)
|
|
72
|
+
self.end_headers()
|
|
73
|
+
self.wfile.write(payload)
|
|
74
|
+
|
|
75
|
+
def do_GET(self) -> None:
|
|
76
|
+
if self.path == "/.well-known/agent-card.json":
|
|
77
|
+
self._json(200, make_agent_card(self._base_url(), auth_config.required))
|
|
78
|
+
else:
|
|
79
|
+
self._json(404, {"error": "not found"})
|
|
80
|
+
|
|
81
|
+
def do_POST(self) -> None:
|
|
82
|
+
if self.path != "/message:send":
|
|
83
|
+
self._json(404, error_response(None, -32601, "method not found"))
|
|
84
|
+
return
|
|
85
|
+
if not self._authorized(auth_config):
|
|
86
|
+
self._json(
|
|
87
|
+
401,
|
|
88
|
+
error_response(None, -32001, "unauthorized"),
|
|
89
|
+
{"WWW-Authenticate": f'Bearer realm="{auth_config.realm}"'},
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
request_id: str | int | None = None
|
|
93
|
+
try:
|
|
94
|
+
if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
|
|
95
|
+
raise A2ARequestError(-32600, "invalid request", 415)
|
|
96
|
+
try:
|
|
97
|
+
size = int(self.headers.get("Content-Length", "0"))
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
raise A2ARequestError(-32600, "invalid request", 400) from exc
|
|
100
|
+
if size <= 0 or size > MAX_REQUEST_BYTES:
|
|
101
|
+
raise A2ARequestError(-32600, "invalid request", 413)
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(self.rfile.read(size))
|
|
104
|
+
except json.JSONDecodeError as exc:
|
|
105
|
+
raise A2ARequestError(-32700, "parse error", 400) from exc
|
|
106
|
+
request = parse_jsonrpc_request(payload)
|
|
107
|
+
request_id = request.id
|
|
108
|
+
envelope = envelope_from_dict(request.params.portmark_envelope)
|
|
109
|
+
result = host.run(envelope)
|
|
110
|
+
self._json(200, success_response(request.id, task_from_run_result(result)))
|
|
111
|
+
except A2ARequestError as exc:
|
|
112
|
+
self._json(exc.http_status, error_response(exc.request_id, exc.code, exc.message))
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception("A2A message submission failed")
|
|
115
|
+
self._json(400, error_response(request_id, -32000, "message submission failed"))
|
|
116
|
+
|
|
117
|
+
def _base_url(self) -> str:
|
|
118
|
+
return f"http://{self.headers.get('Host', '127.0.0.1')}"
|
|
119
|
+
|
|
120
|
+
def _authorized(self, config: A2AAuthConfig) -> bool:
|
|
121
|
+
if not config.bearer_token:
|
|
122
|
+
return True
|
|
123
|
+
header = self.headers.get("Authorization", "")
|
|
124
|
+
prefix = "Bearer "
|
|
125
|
+
if not header.startswith(prefix):
|
|
126
|
+
return False
|
|
127
|
+
return secrets.compare_digest(header[len(prefix):], config.bearer_token)
|
|
128
|
+
|
|
129
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
return A2AHandler
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def auth_from_environment() -> A2AAuthConfig:
|
|
136
|
+
return A2AAuthConfig(os.environ.get("PORTMARK_A2A_TOKEN"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def serve(host: AgentHost, bind: str, port: int, auth: A2AAuthConfig | None = None, enable_hsts: bool = False) -> None:
|
|
140
|
+
ThreadingHTTPServer((bind, port), make_handler(host, auth or auth_from_environment(), enable_hsts)).serve_forever()
|
portmark/a2a_types.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
A2A_PROTOCOL_VERSION = "1.0"
|
|
8
|
+
JSONRPC_VERSION = "2.0"
|
|
9
|
+
MESSAGE_SEND_METHOD = "message/send"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class A2ARequestError(RuntimeError):
|
|
13
|
+
def __init__(self, code: int, message: str, http_status: int = 400, request_id: str | int | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.code = code
|
|
16
|
+
self.message = message
|
|
17
|
+
self.http_status = http_status
|
|
18
|
+
self.request_id = request_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AgentInterface:
|
|
23
|
+
url: str
|
|
24
|
+
protocolBinding: str = "JSONRPC"
|
|
25
|
+
protocolVersion: str = A2A_PROTOCOL_VERSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AgentCapabilities:
|
|
30
|
+
streaming: bool = False
|
|
31
|
+
pushNotifications: bool = False
|
|
32
|
+
stateTransitionHistory: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class AgentSkill:
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
inputModes: tuple[str, ...] = ("application/json",)
|
|
41
|
+
outputModes: tuple[str, ...] = ("application/json",)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SecurityScheme:
|
|
46
|
+
type: str
|
|
47
|
+
scheme: str | None = None
|
|
48
|
+
bearerFormat: str | None = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {key: value for key, value in asdict(self).items() if value is not None}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class AgentCard:
|
|
56
|
+
name: str
|
|
57
|
+
description: str
|
|
58
|
+
url: str
|
|
59
|
+
version: str
|
|
60
|
+
supportedInterfaces: tuple[AgentInterface, ...]
|
|
61
|
+
capabilities: AgentCapabilities
|
|
62
|
+
defaultInputModes: tuple[str, ...]
|
|
63
|
+
defaultOutputModes: tuple[str, ...]
|
|
64
|
+
skills: tuple[AgentSkill, ...]
|
|
65
|
+
protocolVersion: str = A2A_PROTOCOL_VERSION
|
|
66
|
+
securitySchemes: dict[str, SecurityScheme] = field(default_factory=dict)
|
|
67
|
+
security: tuple[dict[str, tuple[str, ...]], ...] = ()
|
|
68
|
+
securityRequirements: tuple[dict[str, tuple[str, ...]], ...] = ()
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
payload = asdict(self)
|
|
72
|
+
payload["securitySchemes"] = {key: scheme.to_dict() for key, scheme in self.securitySchemes.items()}
|
|
73
|
+
if not payload["securitySchemes"]:
|
|
74
|
+
payload.pop("securitySchemes")
|
|
75
|
+
if not payload["security"]:
|
|
76
|
+
payload.pop("security")
|
|
77
|
+
if not payload["securityRequirements"]:
|
|
78
|
+
payload.pop("securityRequirements")
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Message:
|
|
84
|
+
messageId: str
|
|
85
|
+
role: str
|
|
86
|
+
parts: tuple[dict[str, Any], ...]
|
|
87
|
+
taskId: str | None = None
|
|
88
|
+
contextId: str | None = None
|
|
89
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class MessageSendParams:
|
|
94
|
+
message: Message
|
|
95
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def portmark_envelope(self) -> dict[str, Any]:
|
|
99
|
+
envelope = self.metadata.get("portmark_envelope")
|
|
100
|
+
if not isinstance(envelope, dict):
|
|
101
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
102
|
+
return envelope
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class JSONRPCRequest:
|
|
107
|
+
jsonrpc: str
|
|
108
|
+
id: str | int | None
|
|
109
|
+
method: str
|
|
110
|
+
params: MessageSendParams
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class TaskStatus:
|
|
115
|
+
state: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class Task:
|
|
120
|
+
id: str
|
|
121
|
+
status: TaskStatus
|
|
122
|
+
artifacts: tuple[dict[str, Any], ...] = ()
|
|
123
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return asdict(self)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_agent_card(base_url: str, require_bearer_auth: bool) -> dict[str, Any]:
|
|
130
|
+
security_schemes: dict[str, SecurityScheme] = {}
|
|
131
|
+
security: tuple[dict[str, tuple[str, ...]], ...] = ()
|
|
132
|
+
if require_bearer_auth:
|
|
133
|
+
security_schemes = {"bearer": SecurityScheme("http", "bearer", "opaque")}
|
|
134
|
+
security = ({"bearer": ()},)
|
|
135
|
+
card = AgentCard(
|
|
136
|
+
name="Portable Wasm Agent Host",
|
|
137
|
+
description="Runs signed, capability-limited portable agents",
|
|
138
|
+
url=f"{base_url}/message:send",
|
|
139
|
+
version="0.1.0",
|
|
140
|
+
supportedInterfaces=(AgentInterface(f"{base_url}/message:send"),),
|
|
141
|
+
capabilities=AgentCapabilities(),
|
|
142
|
+
defaultInputModes=("application/json",),
|
|
143
|
+
defaultOutputModes=("application/json",),
|
|
144
|
+
skills=(AgentSkill("portmark", "Portmark agent execution", "Execute a signed Portmark agent envelope"),),
|
|
145
|
+
securitySchemes=security_schemes,
|
|
146
|
+
security=security,
|
|
147
|
+
securityRequirements=security,
|
|
148
|
+
)
|
|
149
|
+
return card.to_dict()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_jsonrpc_request(value: Any) -> JSONRPCRequest:
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
raise A2ARequestError(-32600, "invalid request")
|
|
155
|
+
request_id = _valid_request_id(value.get("id"))
|
|
156
|
+
if value.get("jsonrpc") != JSONRPC_VERSION:
|
|
157
|
+
raise A2ARequestError(-32600, "invalid request", request_id=request_id)
|
|
158
|
+
method = value.get("method")
|
|
159
|
+
if not isinstance(method, str):
|
|
160
|
+
raise A2ARequestError(-32600, "invalid request", request_id=request_id)
|
|
161
|
+
if method != MESSAGE_SEND_METHOD:
|
|
162
|
+
raise A2ARequestError(-32601, "method not found", request_id=request_id)
|
|
163
|
+
return JSONRPCRequest(JSONRPC_VERSION, request_id, method, parse_message_send_params(value.get("params")))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_message_send_params(value: Any) -> MessageSendParams:
|
|
167
|
+
if not isinstance(value, dict):
|
|
168
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
169
|
+
message = _parse_message(value.get("message"))
|
|
170
|
+
metadata = _optional_object(value.get("metadata"), "metadata")
|
|
171
|
+
params = MessageSendParams(message, metadata)
|
|
172
|
+
params.portmark_envelope
|
|
173
|
+
return params
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def task_from_run_result(result: Any) -> dict[str, Any]:
|
|
177
|
+
return Task(
|
|
178
|
+
id=result.task_id,
|
|
179
|
+
status=TaskStatus(_task_state(result.status)),
|
|
180
|
+
artifacts=(asdict(result),),
|
|
181
|
+
metadata={"portmark_status": result.status},
|
|
182
|
+
).to_dict()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def success_response(request_id: str | int | None, result: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def error_response(request_id: str | int | None, code: int, message: str) -> dict[str, Any]:
|
|
190
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": code, "message": message}}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_message(value: Any) -> Message:
|
|
194
|
+
if not isinstance(value, dict):
|
|
195
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
196
|
+
message_id = value.get("messageId")
|
|
197
|
+
role = value.get("role")
|
|
198
|
+
parts = value.get("parts")
|
|
199
|
+
if not isinstance(message_id, str) or not message_id:
|
|
200
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
201
|
+
if role not in {"user", "agent"}:
|
|
202
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
203
|
+
if not isinstance(parts, list) or not parts or not all(isinstance(part, dict) for part in parts):
|
|
204
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
205
|
+
return Message(
|
|
206
|
+
message_id,
|
|
207
|
+
role,
|
|
208
|
+
tuple(parts),
|
|
209
|
+
_optional_string(value.get("taskId"), "taskId"),
|
|
210
|
+
_optional_string(value.get("contextId"), "contextId"),
|
|
211
|
+
_optional_object(value.get("metadata"), "metadata"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _optional_object(value: Any, name: str) -> dict[str, Any]:
|
|
216
|
+
if value is None:
|
|
217
|
+
return {}
|
|
218
|
+
if not isinstance(value, dict):
|
|
219
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _optional_string(value: Any, name: str) -> str | None:
|
|
224
|
+
if value is None:
|
|
225
|
+
return None
|
|
226
|
+
if not isinstance(value, str) or not value:
|
|
227
|
+
raise A2ARequestError(-32602, "invalid params")
|
|
228
|
+
return value
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _valid_request_id(value: Any) -> str | int | None:
|
|
232
|
+
if value is None or isinstance(value, (str, int)):
|
|
233
|
+
return value
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _task_state(status: str) -> str:
|
|
238
|
+
return {"completed": "completed", "migrated": "completed", "failed": "failed"}.get(status, "working")
|
portmark/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from .a2a import A2AAuthConfig, serve
|
|
8
|
+
from .config import RuntimeConfig
|
|
9
|
+
from .factory import make_demo_envelope, make_host
|
|
10
|
+
from .logging_config import configure_logging
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Provider-neutral portable agent runtime")
|
|
15
|
+
parser.add_argument("--host-id", help="host identity used for permit audience and signing issuer")
|
|
16
|
+
parser.add_argument("--provider-endpoint", help="generic HTTP model-provider endpoint")
|
|
17
|
+
parser.add_argument("--wasm-component", help="Wasm capsule (.wasm or .wat) implementing the WIT resume ABI")
|
|
18
|
+
parser.add_argument("--store-path", help="SQLite path for durable nonces, checkpoints, and audit heads")
|
|
19
|
+
parser.add_argument("--policy-path", help="JSON host policy path")
|
|
20
|
+
parser.add_argument("--trust-registry-path", help="JSON trust registry path for envelope signing keys")
|
|
21
|
+
parser.add_argument("--reload-policy", action="store_true", help="reload the JSON host policy before each run")
|
|
22
|
+
parser.add_argument("--log-level", help="logging level")
|
|
23
|
+
parser.add_argument("--log-json", action="store_true", help="emit structured JSON logs")
|
|
24
|
+
parser.add_argument("--enable-hsts", action="store_true", help="emit HSTS header when served behind HTTPS")
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
demo = subparsers.add_parser("demo")
|
|
27
|
+
demo.add_argument("goal", nargs="?", default="find portable agent architecture references")
|
|
28
|
+
server = subparsers.add_parser("serve")
|
|
29
|
+
server.add_argument("--bind", default="127.0.0.1")
|
|
30
|
+
server.add_argument("--port", type=int, default=8080)
|
|
31
|
+
server.add_argument("--a2a-token", help="require this bearer token for A2A message/send requests")
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
from .storage import SQLiteRuntimeStore
|
|
34
|
+
|
|
35
|
+
config = RuntimeConfig.from_environment().merged_with_args(args)
|
|
36
|
+
configure_logging(config.log_level, config.log_json)
|
|
37
|
+
store = SQLiteRuntimeStore(config.store_path) if config.store_path else None
|
|
38
|
+
host = make_host(
|
|
39
|
+
config.provider_endpoint,
|
|
40
|
+
host_id=config.host_id,
|
|
41
|
+
wasm_component=config.wasm_component,
|
|
42
|
+
store=store,
|
|
43
|
+
policy_path=config.policy_path,
|
|
44
|
+
trust_registry_path=config.trust_registry_path,
|
|
45
|
+
reload_policy=config.reload_policy,
|
|
46
|
+
)
|
|
47
|
+
if args.command == "demo":
|
|
48
|
+
provider = "wasm" if config.wasm_component else ("http" if config.provider_endpoint else "deterministic")
|
|
49
|
+
result = host.run(make_demo_envelope(host, args.goal, provider))
|
|
50
|
+
print(json.dumps(asdict(result), indent=2))
|
|
51
|
+
else:
|
|
52
|
+
serve(host, args.bind, args.port, A2AAuthConfig(config.a2a_token) if config.a2a_token else None, config.enable_hsts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import AgentState, ProviderDecision
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
WIT_PACKAGE = "portmark:agent@1.0.0"
|
|
11
|
+
WIT_WORLD = "portmark"
|
|
12
|
+
WIT_ABI = "portmark-json-lowered-v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def component_context(state: AgentState, available_tools: tuple[str, ...]) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"wit": {"package": WIT_PACKAGE, "world": WIT_WORLD, "abi": WIT_ABI},
|
|
18
|
+
"state": asdict(state),
|
|
19
|
+
"available_tools": list(available_tools),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def component_checkpoint(state: AgentState) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"task_id": state.task_id,
|
|
26
|
+
"step": state.step,
|
|
27
|
+
"tool_calls": state.tool_calls,
|
|
28
|
+
"memory": state.memory,
|
|
29
|
+
"messages": state.messages,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_component_input(value: dict[str, Any]) -> str:
|
|
34
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_component_decision(raw: str, available_tools: tuple[str, ...]) -> ProviderDecision:
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(raw)
|
|
40
|
+
except json.JSONDecodeError as error:
|
|
41
|
+
raise RuntimeError("Wasm component returned malformed decision JSON") from error
|
|
42
|
+
if not isinstance(value, dict):
|
|
43
|
+
raise RuntimeError("Wasm component decision must be a JSON object")
|
|
44
|
+
outcome = value.get("outcome")
|
|
45
|
+
if outcome == "tool":
|
|
46
|
+
request = value.get("request")
|
|
47
|
+
if not isinstance(request, dict):
|
|
48
|
+
raise RuntimeError("Wasm component tool decision is missing a request")
|
|
49
|
+
name = request.get("name")
|
|
50
|
+
if not isinstance(name, str) or not name:
|
|
51
|
+
raise RuntimeError("Wasm component tool decision has an invalid tool name")
|
|
52
|
+
if name not in available_tools:
|
|
53
|
+
return ProviderDecision("fail", content={"error": "required capability unavailable"})
|
|
54
|
+
arguments = _decode_json_object(request.get("arguments_json", "{}"), "tool arguments")
|
|
55
|
+
return ProviderDecision("tool", name, arguments)
|
|
56
|
+
if outcome == "completed":
|
|
57
|
+
return ProviderDecision("complete", content=_decode_json_value(value.get("content_json", "null"), "completion content"))
|
|
58
|
+
if outcome == "awaiting-input":
|
|
59
|
+
return ProviderDecision("await_input", content=_decode_json_value(value.get("content_json", "null"), "awaiting-input content"))
|
|
60
|
+
if outcome == "migrate":
|
|
61
|
+
destination = value.get("destination")
|
|
62
|
+
if not isinstance(destination, str) or not destination:
|
|
63
|
+
raise RuntimeError("Wasm component migration decision has an invalid destination")
|
|
64
|
+
return ProviderDecision("migrate", destination=destination)
|
|
65
|
+
if outcome == "failed":
|
|
66
|
+
return ProviderDecision("fail", content=_decode_json_value(value.get("content_json", "null"), "failure content"))
|
|
67
|
+
if outcome == "suspended":
|
|
68
|
+
return ProviderDecision("await_input", content=_decode_json_value(value.get("content_json", "null"), "suspension content"))
|
|
69
|
+
raise RuntimeError("Wasm component returned an unknown outcome")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decode_json_object(raw: Any, label: str) -> dict[str, Any]:
|
|
73
|
+
value = _decode_json_value(raw, label)
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise RuntimeError(f"Wasm component {label} must decode to a JSON object")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decode_json_value(raw: Any, label: str) -> Any:
|
|
80
|
+
if not isinstance(raw, str):
|
|
81
|
+
raise RuntimeError(f"Wasm component {label} must be encoded as a JSON string")
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(raw)
|
|
84
|
+
except json.JSONDecodeError as error:
|
|
85
|
+
raise RuntimeError(f"Wasm component {label} is malformed JSON") from error
|
portmark/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RuntimeConfig:
|
|
9
|
+
host_id: str = "host:local-demo"
|
|
10
|
+
provider_endpoint: str | None = None
|
|
11
|
+
wasm_component: str | None = None
|
|
12
|
+
store_path: str | None = None
|
|
13
|
+
policy_path: str | None = None
|
|
14
|
+
trust_registry_path: str | None = None
|
|
15
|
+
reload_policy: bool = False
|
|
16
|
+
a2a_token: str | None = None
|
|
17
|
+
log_level: str = "INFO"
|
|
18
|
+
log_json: bool = False
|
|
19
|
+
enable_hsts: bool = False
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_environment(cls) -> "RuntimeConfig":
|
|
23
|
+
return cls(
|
|
24
|
+
host_id=os.environ.get("PORTMARK_HOST_ID", "host:local-demo"),
|
|
25
|
+
provider_endpoint=os.environ.get("PORTMARK_PROVIDER_ENDPOINT"),
|
|
26
|
+
wasm_component=os.environ.get("PORTMARK_WASM_COMPONENT"),
|
|
27
|
+
store_path=os.environ.get("PORTMARK_STORE_PATH"),
|
|
28
|
+
policy_path=os.environ.get("PORTMARK_POLICY_PATH"),
|
|
29
|
+
trust_registry_path=os.environ.get("PORTMARK_TRUST_REGISTRY_PATH"),
|
|
30
|
+
reload_policy=os.environ.get("PORTMARK_RELOAD_POLICY") == "1",
|
|
31
|
+
a2a_token=os.environ.get("PORTMARK_A2A_TOKEN"),
|
|
32
|
+
log_level=os.environ.get("PORTMARK_LOG_LEVEL", "INFO"),
|
|
33
|
+
log_json=os.environ.get("PORTMARK_LOG_JSON") == "1",
|
|
34
|
+
enable_hsts=os.environ.get("PORTMARK_ENABLE_HSTS") == "1",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def merged_with_args(self, args) -> "RuntimeConfig":
|
|
38
|
+
return RuntimeConfig(
|
|
39
|
+
host_id=args.host_id or self.host_id,
|
|
40
|
+
provider_endpoint=args.provider_endpoint or self.provider_endpoint,
|
|
41
|
+
wasm_component=args.wasm_component or self.wasm_component,
|
|
42
|
+
store_path=args.store_path or self.store_path,
|
|
43
|
+
policy_path=args.policy_path or self.policy_path,
|
|
44
|
+
trust_registry_path=args.trust_registry_path or self.trust_registry_path,
|
|
45
|
+
reload_policy=bool(args.reload_policy or self.reload_policy),
|
|
46
|
+
a2a_token=getattr(args, "a2a_token", None) or self.a2a_token,
|
|
47
|
+
log_level=args.log_level or self.log_level,
|
|
48
|
+
log_json=bool(args.log_json or self.log_json),
|
|
49
|
+
enable_hsts=bool(args.enable_hsts or self.enable_hsts),
|
|
50
|
+
)
|
portmark/factory.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .host import AgentHost
|
|
9
|
+
from .models import AgentEnvelope, AgentManifest, AgentState, Permit, ResourceBudget, ToolGrant
|
|
10
|
+
from .policy import load_host_policy
|
|
11
|
+
from .providers import DeterministicProvider, GenericHttpProvider, WasmDecisionProvider
|
|
12
|
+
from .security import AttestationPolicy, EnvelopeSigner, EnvelopeSigningIdentity, HmacEnvelopeSigner, HostPolicy, load_trust_registry
|
|
13
|
+
from .storage import RuntimeStore, SQLiteRuntimeStore
|
|
14
|
+
from .tools import demo_registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
HOST_ID = "host:local-demo"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def signer_from_environment(host_id: str = HOST_ID, trust_registry_path: str | None = None) -> EnvelopeSigningIdentity:
|
|
21
|
+
registry = load_trust_registry(trust_registry_path) if trust_registry_path else None
|
|
22
|
+
raw_private_key = os.environ.get("PORTMARK_ED25519_PRIVATE_KEY_B64")
|
|
23
|
+
if raw_private_key:
|
|
24
|
+
import base64
|
|
25
|
+
|
|
26
|
+
padding = "=" * (-len(raw_private_key) % 4)
|
|
27
|
+
private_key = base64.urlsafe_b64decode(raw_private_key + padding)
|
|
28
|
+
return EnvelopeSigner.from_private_key_bytes(
|
|
29
|
+
os.environ.get("PORTMARK_SIGNING_KEY_ID", "env-ed25519-key"),
|
|
30
|
+
os.environ.get("PORTMARK_SIGNING_ISSUER", host_id),
|
|
31
|
+
private_key,
|
|
32
|
+
tuple(os.environ.get("PORTMARK_ALLOWED_AUDIENCES", host_id).split(",")),
|
|
33
|
+
registry,
|
|
34
|
+
)
|
|
35
|
+
if os.environ.get("PORTMARK_ALLOW_LEGACY_HMAC") == "1":
|
|
36
|
+
raw = os.environ.get("PORTMARK_SIGNING_KEY", "development-only-signing-key-change-me")
|
|
37
|
+
return HmacEnvelopeSigner(hashlib.sha256(raw.encode()).digest())
|
|
38
|
+
return EnvelopeSigner.generate(issuer=host_id, allowed_audiences=(host_id,))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_host(
|
|
42
|
+
provider_endpoint: str | None = None,
|
|
43
|
+
host_id: str = HOST_ID,
|
|
44
|
+
signer: EnvelopeSigningIdentity | None = None,
|
|
45
|
+
wasm_component: str | None = None,
|
|
46
|
+
store: RuntimeStore | None = None,
|
|
47
|
+
attestation_policy: AttestationPolicy | None = None,
|
|
48
|
+
policy_path: str | None = None,
|
|
49
|
+
trust_registry_path: str | None = None,
|
|
50
|
+
reload_policy: bool = False,
|
|
51
|
+
) -> AgentHost:
|
|
52
|
+
providers = {"deterministic": DeterministicProvider()}
|
|
53
|
+
if provider_endpoint:
|
|
54
|
+
providers["http"] = GenericHttpProvider(provider_endpoint, os.environ.get("MODEL_PROVIDER_TOKEN"))
|
|
55
|
+
if wasm_component:
|
|
56
|
+
providers["wasm"] = WasmDecisionProvider.from_file(wasm_component)
|
|
57
|
+
configured_policy_path = policy_path or os.environ.get("PORTMARK_POLICY_PATH")
|
|
58
|
+
configured_trust_registry_path = trust_registry_path or os.environ.get("PORTMARK_TRUST_REGISTRY_PATH")
|
|
59
|
+
policy_loader = (lambda: load_host_policy(configured_policy_path, host_id)) if configured_policy_path else None
|
|
60
|
+
policy = policy_loader() if policy_loader else HostPolicy(
|
|
61
|
+
host_id,
|
|
62
|
+
grants=(ToolGrant("catalog.search", {"max_limit": 5}), ToolGrant("payments.reserve", {"max_amount": 100, "currency": "USD"})),
|
|
63
|
+
budget=ResourceBudget(max_steps=10, max_tool_calls=5, max_output_bytes=65_536),
|
|
64
|
+
tool_impacts={"catalog.search": "low", "payments.reserve": "external-payment"},
|
|
65
|
+
)
|
|
66
|
+
configured_store = store
|
|
67
|
+
if configured_store is None and os.environ.get("PORTMARK_STORE_PATH"):
|
|
68
|
+
configured_store = SQLiteRuntimeStore(os.environ["PORTMARK_STORE_PATH"])
|
|
69
|
+
return AgentHost(
|
|
70
|
+
host_id,
|
|
71
|
+
signer or signer_from_environment(host_id, configured_trust_registry_path),
|
|
72
|
+
policy,
|
|
73
|
+
demo_registry(),
|
|
74
|
+
providers,
|
|
75
|
+
configured_store,
|
|
76
|
+
attestation_policy,
|
|
77
|
+
policy_loader,
|
|
78
|
+
reload_policy,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def make_demo_envelope(host: AgentHost, goal: str, provider: str = "deterministic") -> AgentEnvelope:
|
|
83
|
+
configured = host.providers.get(provider)
|
|
84
|
+
digest = getattr(configured, "component_digest", "python:reference-agent-v1")
|
|
85
|
+
manifest = AgentManifest("agent:demo", "1.0.0", provider, ("catalog.search", "payments.reserve"), digest)
|
|
86
|
+
permit = Permit(
|
|
87
|
+
issuer=getattr(host.signer, "issuer", host.host_id), subject=manifest.agent_id, audience=host.host_id,
|
|
88
|
+
expires_at=int(time.time()) + 3600, nonce=secrets.token_hex(16),
|
|
89
|
+
grants=(ToolGrant("catalog.search", {"max_limit": 3}),),
|
|
90
|
+
budget=ResourceBudget(max_steps=6, max_tool_calls=2, max_output_bytes=32_768),
|
|
91
|
+
)
|
|
92
|
+
return host.signer.seal(AgentEnvelope(manifest, permit, AgentState(secrets.token_hex(8), goal)))
|