portmark 0.1.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.
portmark-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: portmark
3
+ Version: 0.1.0
4
+ Summary: Provider-neutral, capability-secured portable agent reference runtime
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ License-File: LICENSE
8
+ Requires-Dist: cryptography>=42
9
+ Dynamic: license-file
@@ -0,0 +1,211 @@
1
+ # Portmark
2
+
3
+ [![CI](https://github.com/Itsthewayofyou/portmark/actions/workflows/ci.yml/badge.svg)](https://github.com/Itsthewayofyou/portmark/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ **Run someone else's AI agent on your machine without trusting it.**
7
+
8
+ An agent arrives as a signed envelope carrying its manifest, permit, state checkpoint, and
9
+ component identity. Your host verifies the signature itself, intersects the agent's requested
10
+ authority with your local policy, and mediates every single tool call. The agent's model
11
+ proposes actions; it never executes them and never sees your credentials.
12
+
13
+ ## What the host is guaranteed
14
+
15
+ These are enforced by the host, not requested politely from the agent:
16
+
17
+ - **Your policy is a hard ceiling.** `effective_permit` intersects three sets: what the agent's
18
+ manifest asks for, what its permit grants, and what your host policy allows. An agent arriving
19
+ with a permit for `*` still gets only what you allow.
20
+ ([`security.py`](src/portmark/security.py), `intersect_grants`)
21
+ - **Budgets take the minimum.** Steps, tool calls, and output bytes are each `min(agent, host)`.
22
+ A visitor cannot raise its own limits. ([`models.py`](src/portmark/models.py), `ResourceBudget.intersect`)
23
+ - **Wasm capsules declaring any import are refused, not sandboxed.** The guest therefore has no
24
+ filesystem, network, process, environment, clock, randomness, or credential access at all.
25
+ ([`wasm_runner.mjs`](src/portmark/wasm_runner.mjs))
26
+ - **Permits can only narrow on migration.** One hop, bound to the named destination, grants and
27
+ budgets cannot increase, and further delegation is disabled. A visiting agent cannot accept a
28
+ narrow permit at your door and widen it on the next hop.
29
+ - **Every run is auditable.** A hash-chained audit log records the accepted agent, the active
30
+ policy version and hash, each proposal, and each executed tool call.
31
+
32
+ Portmark runs entirely offline by default. The deterministic provider is for demonstrations and
33
+ tests. `GenericHttpProvider` connects the same runtime to llama.cpp, Ollama through an adapter, a
34
+ hosted model gateway, or anything implementing the small decision contract below.
35
+
36
+ ## Quickstart
37
+
38
+ ```bash
39
+ git clone https://github.com/Itsthewayofyou/portmark.git
40
+ cd portmark
41
+ PYTHONPATH=src python -m portmark.cli demo "research modern mobile agents"
42
+ ```
43
+
44
+ No installation and no network access required. Examples below use bash; on PowerShell, write
45
+ `$env:PYTHONPATH = "src"` on its own line instead of the inline prefix.
46
+
47
+ ## Security boundary
48
+
49
+ ```text
50
+ A2A request / local CLI
51
+ |
52
+ signed agent envelope
53
+ v
54
+ Host verification -> permit intersection -> provider proposal
55
+ |
56
+ host validates every action
57
+ v
58
+ capability-scoped tools
59
+ ```
60
+
61
+ The provider proposes actions but never receives host credentials and never authorizes its own
62
+ tool calls. The host enforces subject, audience, expiry, replay nonce, tool grants, argument
63
+ constraints, step budgets, tool-call budgets, output limits, and a hash-chained audit log.
64
+
65
+ Host policy can be loaded from JSON and every run records the active policy version and hash in
66
+ the audit log. High-impact tools such as external payments, destructive actions, credentialed
67
+ access, and data-exfiltration-risk actions require signed approval tokens bound to the task,
68
+ permit nonce, tool arguments, and active policy hash. See [POLICY.md](POLICY.md) for the policy
69
+ format, reload behavior, approval-token contract, and audit events.
70
+
71
+ Migration requires `delegation_allowed` on the incoming permit. The source host can then create
72
+ only a narrower, one-hop, destination-bound permit: the effective grants and budgets cannot
73
+ increase, the audience changes to the named destination, the replay nonce is replaced, and
74
+ further delegation is disabled. Both hosts must belong to the same configured trust domain in
75
+ this reference implementation.
76
+
77
+ Hosts can require signed confidential-computing attestation evidence before sensitive execution
78
+ or delegated migration. The reference verifier binds the attested host identity, relying-party
79
+ audience, approved measurement, freshness window, optional nonce, and verifier signature before
80
+ the host runs the agent or emits a migration envelope. See [ATTESTATION.md](ATTESTATION.md) for
81
+ the evidence format, verification flow, sealed-storage decision, and residual risks.
82
+
83
+ Envelopes are signed with Ed25519 by default and verified through a key-ID-based trust registry.
84
+ See [SIGNING_KEYS.md](SIGNING_KEYS.md) for key generation, rotation, revocation, and trust
85
+ bootstrap guidance. The legacy HMAC signer is retained only for explicit dependency-free demos.
86
+
87
+ ## Run it
88
+
89
+ Install the project for normal package usage:
90
+
91
+ ```bash
92
+ python -m pip install -e .
93
+ portmark demo "research modern mobile agents"
94
+ ```
95
+
96
+ Execute the included Wasm capsule using the WIT-shaped `resume(context-json, checkpoint-json)`
97
+ binding:
98
+
99
+ ```bash
100
+ PYTHONPATH=src python -m portmark.cli --wasm-component capsules/research-agent.wasm.b64 demo "research modern mobile agents"
101
+ ```
102
+
103
+ The host executes each capsule in a short-lived worker with a strict deadline and rejects every
104
+ module declaring an import. Its signed SHA-256 digest is checked before execution. Tool actions
105
+ returned by the capsule still pass through the same host permit and argument enforcement as
106
+ model-provider proposals. See [WASM_COMPONENTS.md](WASM_COMPONENTS.md) for the WIT binding
107
+ contract.
108
+
109
+ Run tests:
110
+
111
+ ```bash
112
+ PYTHONPATH=src python -m unittest discover -s tests -v
113
+ ```
114
+
115
+ Run the A2A-facing HTTP service:
116
+
117
+ ```bash
118
+ PYTHONPATH=src python -m portmark.cli serve --port 8080
119
+ ```
120
+
121
+ Require bearer authentication for A2A message submission:
122
+
123
+ ```bash
124
+ PORTMARK_A2A_TOKEN=change-me PYTHONPATH=src python -m portmark.cli serve --port 8080
125
+ ```
126
+
127
+ The Agent Card is available at `/.well-known/agent-card.json`; signed envelopes are submitted to
128
+ `/message:send` with the A2A JSON-RPC `message/send` method. See [A2A.md](A2A.md) for the Agent
129
+ Card fields, request shape, authentication profile, and error behavior.
130
+
131
+ Persist replay nonces, checkpoints, and audit heads with SQLite:
132
+
133
+ ```bash
134
+ PYTHONPATH=src python -m portmark.cli --store-path runtime.sqlite demo "research modern mobile agents"
135
+ ```
136
+
137
+ See [RUNTIME_STORAGE.md](RUNTIME_STORAGE.md) for the storage schema, transaction guarantees, and
138
+ recovery behavior.
139
+
140
+ Load policy from JSON:
141
+
142
+ ```bash
143
+ PYTHONPATH=src python -m portmark.cli --policy-path examples/host-policy.json --reload-policy demo "research modern mobile agents"
144
+ ```
145
+
146
+ See [OPERATIONS.md](OPERATIONS.md) for runtime configuration, trust registry format, policy
147
+ updates, audit verification, backup/restore, and incident response.
148
+
149
+ ## Generic provider contract
150
+
151
+ Start the CLI with `--provider-endpoint URL`. The runtime sends:
152
+
153
+ ```json
154
+ {"state": {"goal": "..."}, "available_tools": ["catalog.search"]}
155
+ ```
156
+
157
+ The provider responds with one decision:
158
+
159
+ ```json
160
+ {"kind": "tool", "tool": "catalog.search", "arguments": {"query": "...", "limit": 3}}
161
+ ```
162
+
163
+ or:
164
+
165
+ ```json
166
+ {"kind": "complete", "content": {"answer": "..."}}
167
+ ```
168
+
169
+ `MODEL_PROVIDER_TOKEN` optionally supplies a bearer token. It is held by the host adapter, not
170
+ included in the portable envelope.
171
+
172
+ ## WebAssembly component boundary
173
+
174
+ [`wit/portmark.wit`](wit/portmark.wit) defines the Wasm Component Model decision interface. The
175
+ capsule exports a checkpoint-based `resume` operation that receives structured context and
176
+ checkpoint JSON and returns a structured outcome. A compiled capsule therefore cannot directly
177
+ acquire filesystem, network, process, database, or credential access; the host must explicitly
178
+ validate and mediate every requested action.
179
+
180
+ The runnable Node WebAssembly adapter uses the JSON-lowered WIT binding documented in
181
+ [WASM_COMPONENTS.md](WASM_COMPONENTS.md). Strong migration is implemented as
182
+ checkpoint-and-resume: native stacks, threads, sockets, and file descriptors never cross hosts.
183
+
184
+ ## Production status
185
+
186
+ Portmark is **reference-complete with six named substitution points**. Signing and trust,
187
+ transactional persistence, the A2A 1.0 surface, external policy with approval gates, WIT-shaped
188
+ Wasm execution, and confidential-computing attestation are all implemented and covered by the
189
+ regression suite. [PRODUCTION_TASKS.md](PRODUCTION_TASKS.md) holds the task-level record.
190
+
191
+ It is a reference implementation: read it, fork it, and substitute the seams below. It is not
192
+ intended as a drop-in production dependency.
193
+
194
+ ## Deployment integration points
195
+
196
+ Portmark is provider-neutral by design. The following are deliberate seams for the deploying
197
+ environment to fill, not unfinished work. Each has a working reference implementation and a
198
+ stable interface to substitute against.
199
+
200
+ | Seam | Reference implementation | What a production deployment supplies |
201
+ | --- | --- | --- |
202
+ | Trust registry | JSON registry loaded from `PORTMARK_TRUST_REGISTRY_PATH`, with key IDs, issuers, audiences, validity windows, and revocation | PKI- or KMS-backed key distribution and rotation |
203
+ | Storage | `SQLiteRuntimeStore` behind the `RuntimeStore` protocol | a shared database for multi-host deployments |
204
+ | Wasm bindings | WIT contract executed through a JSON-lowered adapter on Node | native Component Model bindings once a Python runtime exposes them |
205
+ | A2A types | generated-style A2A 1.0 subset isolated in `a2a_types.py` | the official generated SDK when a Python package is published |
206
+ | Attestation | signed mock evidence verified by `AttestationPolicy` against RATS-style roles | the target platform's TEE quote verifier and sealed-storage backend |
207
+ | Approvals | locally signed approval tokens bound to task, nonce, arguments, and policy hash | an approval service tied to operator identity and change management |
208
+
209
+ ## License
210
+
211
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "portmark"
7
+ version = "0.1.0"
8
+ description = "Provider-neutral, capability-secured portable agent reference runtime"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ dependencies = ["cryptography>=42"]
13
+
14
+ [project.scripts]
15
+ portmark = "portmark.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.setuptools.package-data]
21
+ portmark = ["wasm_runner.mjs"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Portable, provider-neutral agent runtime."""
2
+
3
+ from .host import AgentHost
4
+ from .models import AgentEnvelope, AgentManifest, AgentState, Permit
5
+
6
+ __all__ = ["AgentEnvelope", "AgentHost", "AgentManifest", "AgentState", "Permit"]
7
+
@@ -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()
@@ -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")