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 +21 -0
- portmark-0.1.0/PKG-INFO +9 -0
- portmark-0.1.0/README.md +211 -0
- portmark-0.1.0/pyproject.toml +21 -0
- portmark-0.1.0/setup.cfg +4 -0
- portmark-0.1.0/src/portmark/__init__.py +7 -0
- portmark-0.1.0/src/portmark/a2a.py +140 -0
- portmark-0.1.0/src/portmark/a2a_types.py +238 -0
- portmark-0.1.0/src/portmark/cli.py +56 -0
- portmark-0.1.0/src/portmark/component_bindings.py +85 -0
- portmark-0.1.0/src/portmark/config.py +50 -0
- portmark-0.1.0/src/portmark/factory.py +92 -0
- portmark-0.1.0/src/portmark/host.py +252 -0
- portmark-0.1.0/src/portmark/logging_config.py +28 -0
- portmark-0.1.0/src/portmark/models.py +136 -0
- portmark-0.1.0/src/portmark/policy.py +127 -0
- portmark-0.1.0/src/portmark/providers.py +107 -0
- portmark-0.1.0/src/portmark/security.py +616 -0
- portmark-0.1.0/src/portmark/storage.py +326 -0
- portmark-0.1.0/src/portmark/tools.py +51 -0
- portmark-0.1.0/src/portmark/wasm_runner.mjs +61 -0
- portmark-0.1.0/src/portmark.egg-info/PKG-INFO +9 -0
- portmark-0.1.0/src/portmark.egg-info/SOURCES.txt +26 -0
- portmark-0.1.0/src/portmark.egg-info/dependency_links.txt +1 -0
- portmark-0.1.0/src/portmark.egg-info/entry_points.txt +2 -0
- portmark-0.1.0/src/portmark.egg-info/requires.txt +1 -0
- portmark-0.1.0/src/portmark.egg-info/top_level.txt +1 -0
- portmark-0.1.0/tests/test_runtime.py +939 -0
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.
|
portmark-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
portmark-0.1.0/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Portmark
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Itsthewayofyou/portmark/actions/workflows/ci.yml)
|
|
4
|
+
[](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"]
|
portmark-0.1.0/setup.cfg
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()
|
|
@@ -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")
|