agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The 64-entry SAS emoji table (SPEC 11), index -> (emoji, name).
|
|
2
|
+
|
|
3
|
+
Mirrors `testdata/sas-emoji.json`; a contract test keeps the two equal.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
SAS_EMOJI: tuple[tuple[str, str], ...] = (
|
|
9
|
+
("🐶", "Dog"),
|
|
10
|
+
("🐱", "Cat"),
|
|
11
|
+
("🦁", "Lion"),
|
|
12
|
+
("🐎", "Horse"),
|
|
13
|
+
("🦄", "Unicorn"),
|
|
14
|
+
("🐷", "Pig"),
|
|
15
|
+
("🐘", "Elephant"),
|
|
16
|
+
("🐰", "Rabbit"),
|
|
17
|
+
("🐼", "Panda"),
|
|
18
|
+
("🐓", "Rooster"),
|
|
19
|
+
("🐧", "Penguin"),
|
|
20
|
+
("🐢", "Turtle"),
|
|
21
|
+
("🐟", "Fish"),
|
|
22
|
+
("🐙", "Octopus"),
|
|
23
|
+
("🦋", "Butterfly"),
|
|
24
|
+
("🌷", "Flower"),
|
|
25
|
+
("🌳", "Tree"),
|
|
26
|
+
("🌵", "Cactus"),
|
|
27
|
+
("🍄", "Mushroom"),
|
|
28
|
+
("🌏", "Globe"),
|
|
29
|
+
("🌙", "Moon"),
|
|
30
|
+
("☁️", "Cloud"),
|
|
31
|
+
("🔥", "Fire"),
|
|
32
|
+
("🍌", "Banana"),
|
|
33
|
+
("🍎", "Apple"),
|
|
34
|
+
("🍓", "Strawberry"),
|
|
35
|
+
("🌽", "Corn"),
|
|
36
|
+
("🍕", "Pizza"),
|
|
37
|
+
("🎂", "Cake"),
|
|
38
|
+
("❤️", "Heart"),
|
|
39
|
+
("😀", "Smiley"),
|
|
40
|
+
("\U0001f916", "Robot"),
|
|
41
|
+
("🎩", "Hat"),
|
|
42
|
+
("👓", "Glasses"),
|
|
43
|
+
("🔧", "Spanner"),
|
|
44
|
+
("🎅", "Santa"),
|
|
45
|
+
("👍", "Thumbs Up"),
|
|
46
|
+
("☂️", "Umbrella"),
|
|
47
|
+
("⌛", "Hourglass"),
|
|
48
|
+
("⏰", "Clock"),
|
|
49
|
+
("🎁", "Gift"),
|
|
50
|
+
("💡", "Light Bulb"),
|
|
51
|
+
("📕", "Book"),
|
|
52
|
+
("✏️", "Pencil"),
|
|
53
|
+
("📎", "Paperclip"),
|
|
54
|
+
("✂️", "Scissors"),
|
|
55
|
+
("🔒", "Lock"),
|
|
56
|
+
("🔑", "Key"),
|
|
57
|
+
("🔨", "Hammer"),
|
|
58
|
+
("☎️", "Telephone"),
|
|
59
|
+
("🏁", "Flag"),
|
|
60
|
+
("🚂", "Train"),
|
|
61
|
+
("🚲", "Bicycle"),
|
|
62
|
+
("✈️", "Aeroplane"),
|
|
63
|
+
("🚀", "Rocket"),
|
|
64
|
+
("🏆", "Trophy"),
|
|
65
|
+
("⚽", "Ball"),
|
|
66
|
+
("🎸", "Guitar"),
|
|
67
|
+
("🎺", "Trumpet"),
|
|
68
|
+
("🔔", "Bell"),
|
|
69
|
+
("⚓", "Anchor"),
|
|
70
|
+
("🎧", "Headphones"),
|
|
71
|
+
("📁", "Folder"),
|
|
72
|
+
("📌", "Pin"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_emoji(indices: list[int]) -> str:
|
|
77
|
+
"""The emoji row for the 7 six-bit SAS indices."""
|
|
78
|
+
return " ".join(SAS_EMOJI[i][0] for i in indices)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def describe_emoji(indices: list[int]) -> str:
|
|
82
|
+
"""The same row as names, for terminals without emoji fonts."""
|
|
83
|
+
return ", ".join(SAS_EMOJI[i][1] for i in indices)
|
agentconnect/schemas.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Schemas for the kinds that carry no content: the controls (SPEC §7.3),
|
|
2
|
+
Agent Cards (§10.5) and task cancellations (§7.2).
|
|
3
|
+
|
|
4
|
+
Only ``a2a.message``, ``a2a.task_status`` and ``a2a.task_artifact`` carry
|
|
5
|
+
A2A parts. Every other kind has a known shape, and :func:`normalize` keeps
|
|
6
|
+
only that shape:
|
|
7
|
+
|
|
8
|
+
* **Unknown fields are stripped** (SPEC §7.2, forward compatibility): a
|
|
9
|
+
newer peer's extra field is never shown to the agent, and never counts
|
|
10
|
+
against the peer.
|
|
11
|
+
* **Content is refused.** A ``parts`` list anywhere in the body, or a
|
|
12
|
+
top-level content member (``text``, ``data``, ``file``, ``raw``) that the
|
|
13
|
+
kind doesn't define, makes the message ``malformed``: a member can't
|
|
14
|
+
smuggle text, data or files to an agent (or past ``require_approval``)
|
|
15
|
+
under a kind that is never held as content and never shown as text. So
|
|
16
|
+
does a known field of the wrong type.
|
|
17
|
+
* **Agent Cards are checked, never rewritten** (the listing limits of
|
|
18
|
+
ADR-006, Decision 1): every known A2A v1.0 field is kept exactly as sent,
|
|
19
|
+
so a signature over it still verifies; a string, list or object over its
|
|
20
|
+
cap makes the card ``malformed`` instead of being cut. Unknown fields,
|
|
21
|
+
which no A2A signature covers, are left out at every level.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import unicodedata
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
CONTENT_KINDS = frozenset({"a2a.message", "a2a.task_status", "a2a.task_artifact"})
|
|
33
|
+
CONTENT_MEMBERS = frozenset({"parts", "text", "data", "file", "raw"})
|
|
34
|
+
MAX_REASON = 200
|
|
35
|
+
MAX_ID = 256
|
|
36
|
+
# Floor epochs: what a signed 64-bit SQLite integer holds. Floor counters
|
|
37
|
+
# start again at each epoch, so no honest one comes near 2**32 (SPEC 9.3).
|
|
38
|
+
MAX_COUNTER = 2**63 - 1
|
|
39
|
+
MAX_FLOOR_COUNTER = 2**32
|
|
40
|
+
INSTANCE_ID = re.compile(r"[0-9a-f]{16}")
|
|
41
|
+
MAX_WORD = 64 # a pause reason, an adapter name, an accepted part kind
|
|
42
|
+
MAX_ADAPTERS = 16
|
|
43
|
+
MAX_DEPTH = 32
|
|
44
|
+
# ADR-006, Decision 1: the listing caps, applied to every card.
|
|
45
|
+
MAX_CARD_NAME = 80
|
|
46
|
+
MAX_CARD_DESCRIPTION = 500
|
|
47
|
+
MAX_CARD_VERSION = 32
|
|
48
|
+
MAX_SKILLS = 16
|
|
49
|
+
MAX_SKILL_NAME = 80
|
|
50
|
+
MAX_TAGS = 8
|
|
51
|
+
MAX_TAG = 32
|
|
52
|
+
MAX_EXAMPLES = 4
|
|
53
|
+
MAX_EXAMPLE = 200
|
|
54
|
+
MAX_MODES = 8
|
|
55
|
+
MAX_MODE = 128
|
|
56
|
+
MAX_EXTENSIONS = 8
|
|
57
|
+
MAX_URI = 512
|
|
58
|
+
MAX_PARAMS_BYTES = 4096
|
|
59
|
+
MAX_CARD_BYTES = 32 * 1024
|
|
60
|
+
MAX_OBJECT_BYTES = 4096 # securitySchemes, securityRequirements, one signature
|
|
61
|
+
MAX_INTERFACES = 8
|
|
62
|
+
MAX_SIGNATURES = 4
|
|
63
|
+
MAX_METADATA_BYTES = 4096
|
|
64
|
+
# The A2A v1.0 AgentCard, AgentSkill and CancelTaskRequest fields (JSON names).
|
|
65
|
+
CARD_FIELDS = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"name",
|
|
68
|
+
"description",
|
|
69
|
+
"supportedInterfaces",
|
|
70
|
+
"provider",
|
|
71
|
+
"version",
|
|
72
|
+
"documentationUrl",
|
|
73
|
+
"capabilities",
|
|
74
|
+
"securitySchemes",
|
|
75
|
+
"securityRequirements",
|
|
76
|
+
"defaultInputModes",
|
|
77
|
+
"defaultOutputModes",
|
|
78
|
+
"skills",
|
|
79
|
+
"signatures",
|
|
80
|
+
"iconUrl",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
SKILL_FIELDS = frozenset(
|
|
84
|
+
{
|
|
85
|
+
"id",
|
|
86
|
+
"name",
|
|
87
|
+
"description",
|
|
88
|
+
"tags",
|
|
89
|
+
"examples",
|
|
90
|
+
"inputModes",
|
|
91
|
+
"outputModes",
|
|
92
|
+
"securityRequirements",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
CAPABILITY_FIELDS = frozenset({"streaming", "pushNotifications", "extensions", "extendedAgentCard"})
|
|
96
|
+
EXTENSION_FIELDS = frozenset({"uri", "description", "required", "params"})
|
|
97
|
+
PROVIDER_FIELDS = frozenset({"organization", "url"})
|
|
98
|
+
INTERFACE_FIELDS = frozenset({"url", "protocolBinding", "protocolVersion", "tenant"})
|
|
99
|
+
SIGNATURE_FIELDS = frozenset({"protected", "signature", "header"})
|
|
100
|
+
CANCEL_FIELDS = frozenset({"id", "metadata", "tenant"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MalformedError(ValueError):
|
|
104
|
+
"""The body doesn't fit its kind's schema."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _has_parts(value: Any, depth: int = 0) -> bool:
|
|
108
|
+
"""Whether a ``parts`` key appears anywhere (or the body nests too deep)."""
|
|
109
|
+
if depth > MAX_DEPTH:
|
|
110
|
+
return True
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
return "parts" in value or any(_has_parts(v, depth + 1) for v in value.values())
|
|
113
|
+
if isinstance(value, list):
|
|
114
|
+
return any(_has_parts(v, depth + 1) for v in value)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _text(value: Any, longest: int) -> str:
|
|
119
|
+
"""A string field: control characters become spaces, cut to ``longest``."""
|
|
120
|
+
if not isinstance(value, str):
|
|
121
|
+
raise MalformedError
|
|
122
|
+
flat = "".join(" " if unicodedata.category(c) == "Cc" else c for c in str(value))
|
|
123
|
+
return flat[:longest]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _word(value: Any, longest: int = MAX_WORD) -> str:
|
|
127
|
+
if not isinstance(value, str) or not 0 < len(value) <= longest:
|
|
128
|
+
raise MalformedError
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _words(value: Any, most: int, longest: int) -> list[str]:
|
|
133
|
+
if not isinstance(value, list):
|
|
134
|
+
raise MalformedError
|
|
135
|
+
return [_text(v, longest) for v in value[:most]]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _small(value: Any, most: int) -> Any:
|
|
139
|
+
"""A JSON value of at most ``most`` bytes, kept as it is."""
|
|
140
|
+
if len(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) > most:
|
|
141
|
+
raise MalformedError
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _empty(_body: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _close(body: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
return {"reason": _text(body["reason"], MAX_REASON)} if "reason" in body else {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _identity(value: Any) -> str:
|
|
154
|
+
if not (isinstance(value, str) and value.startswith("ac:v0:") and len(value) <= MAX_ID):
|
|
155
|
+
raise MalformedError
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _counter(value: Any, most: int = MAX_COUNTER) -> int:
|
|
160
|
+
"""A floor counter (at most ``MAX_FLOOR_COUNTER``) or epoch (SPEC 9.3)."""
|
|
161
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= most:
|
|
162
|
+
raise MalformedError
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _copy_key(copy: Any, counter: int) -> dict[str, Any]:
|
|
167
|
+
"""The key a copy names: a grant's (an identity and its instance) or a
|
|
168
|
+
commit's reset (``""``, ``""``) or drop (``""``, the identity dropped),
|
|
169
|
+
which have counter 0."""
|
|
170
|
+
if not isinstance(copy, dict):
|
|
171
|
+
raise MalformedError
|
|
172
|
+
sender, instance = copy.get("sender"), copy.get("instance")
|
|
173
|
+
if sender == "":
|
|
174
|
+
if counter != 0 or not (instance == "" or _identity(instance)):
|
|
175
|
+
raise MalformedError
|
|
176
|
+
else:
|
|
177
|
+
_identity(sender)
|
|
178
|
+
if not (isinstance(instance, str) and INSTANCE_ID.fullmatch(instance)):
|
|
179
|
+
raise MalformedError
|
|
180
|
+
return {
|
|
181
|
+
"epoch": _counter(copy.get("epoch")),
|
|
182
|
+
"sender": sender,
|
|
183
|
+
"instance": instance,
|
|
184
|
+
"join": _counter(copy.get("join")),
|
|
185
|
+
"seq": _counter(copy.get("seq"), MAX_FLOOR_COUNTER),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _floor(body: dict[str, Any]) -> dict[str, Any]:
|
|
190
|
+
"""``{holder, counter}``, and for the copy of the floor sent to members
|
|
191
|
+
that join, ``copy: {epoch, sender, instance, join, seq}``: the key of the
|
|
192
|
+
write copied, the epoch its recipients joined in and the copy's place
|
|
193
|
+
among the copier's copies for that join (SPEC 9.3)."""
|
|
194
|
+
holder = body.get("holder", ...)
|
|
195
|
+
if holder is not None:
|
|
196
|
+
holder = _identity(holder)
|
|
197
|
+
counter = _counter(body.get("counter"), MAX_FLOOR_COUNTER)
|
|
198
|
+
out: dict[str, Any] = {"holder": holder, "counter": counter}
|
|
199
|
+
if "copy" in body:
|
|
200
|
+
out["copy"] = _copy_key(body["copy"], counter)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _pause(body: dict[str, Any]) -> dict[str, Any]:
|
|
205
|
+
# Any reason is kept (a newer peer may have new ones); it is a word.
|
|
206
|
+
return {"reason": _word(body.get("reason"))}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _capabilities(body: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
adapters, accepts = body.get("adapters"), body.get("accepts")
|
|
211
|
+
if not isinstance(adapters, list) or not isinstance(accepts, list):
|
|
212
|
+
raise MalformedError
|
|
213
|
+
return {
|
|
214
|
+
"adapters": [_word(a) for a in adapters[:MAX_ADAPTERS]],
|
|
215
|
+
"accepts": [_word(a) for a in accepts[:MAX_ADAPTERS]],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _capped(value: Any, longest: int) -> str:
|
|
220
|
+
"""A card string, kept exactly as sent (a signature covers it): over the
|
|
221
|
+
cap it is refused, never cut. Control characters are cleaned only where
|
|
222
|
+
it is shown (the CLI, the SPEC 10.1 notice)."""
|
|
223
|
+
if not isinstance(value, str) or len(value) > longest:
|
|
224
|
+
raise MalformedError
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _capped_list(value: Any, most: int, longest: int) -> list[str]:
|
|
229
|
+
if not isinstance(value, list) or len(value) > most:
|
|
230
|
+
raise MalformedError
|
|
231
|
+
return [_capped(v, longest) for v in value]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _known(value: Any, fields: frozenset[str]) -> dict[str, Any]:
|
|
235
|
+
"""An object with its known fields only: an unknown field is no field of
|
|
236
|
+
the A2A object, so no signature covers it, and no free-text channel."""
|
|
237
|
+
if not isinstance(value, dict):
|
|
238
|
+
raise MalformedError
|
|
239
|
+
return {k: v for k, v in value.items() if k in fields}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _skill(skill: Any) -> dict[str, Any]:
|
|
243
|
+
out = _known(skill, SKILL_FIELDS)
|
|
244
|
+
out["id"] = _capped(out.get("id"), MAX_SKILL_NAME)
|
|
245
|
+
for key, longest in (("name", MAX_SKILL_NAME), ("description", MAX_CARD_DESCRIPTION)):
|
|
246
|
+
if key in out:
|
|
247
|
+
out[key] = _capped(out[key], longest)
|
|
248
|
+
if "tags" in out:
|
|
249
|
+
out["tags"] = _capped_list(out["tags"], MAX_TAGS, MAX_TAG)
|
|
250
|
+
if "examples" in out:
|
|
251
|
+
out["examples"] = _capped_list(out["examples"], MAX_EXAMPLES, MAX_EXAMPLE)
|
|
252
|
+
for key in ("inputModes", "outputModes"):
|
|
253
|
+
if key in out:
|
|
254
|
+
out[key] = _capped_list(out[key], MAX_MODES, MAX_MODE)
|
|
255
|
+
if "securityRequirements" in out:
|
|
256
|
+
out["securityRequirements"] = _small(out["securityRequirements"], MAX_OBJECT_BYTES)
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _extension(ext: Any) -> dict[str, Any]:
|
|
261
|
+
out = _known(ext, EXTENSION_FIELDS)
|
|
262
|
+
out["uri"] = _capped(out.get("uri"), MAX_URI)
|
|
263
|
+
if "description" in out:
|
|
264
|
+
out["description"] = _capped(out["description"], MAX_CARD_DESCRIPTION)
|
|
265
|
+
if "required" in out and not isinstance(out["required"], bool):
|
|
266
|
+
raise MalformedError
|
|
267
|
+
if "params" in out:
|
|
268
|
+
if not isinstance(out["params"], dict):
|
|
269
|
+
raise MalformedError
|
|
270
|
+
out["params"] = _small(out["params"], MAX_PARAMS_BYTES)
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _card_capabilities(caps: Any) -> dict[str, Any]:
|
|
275
|
+
out = _known(caps, CAPABILITY_FIELDS)
|
|
276
|
+
for key in ("streaming", "pushNotifications", "extendedAgentCard"):
|
|
277
|
+
if key in out and not isinstance(out[key], bool):
|
|
278
|
+
raise MalformedError
|
|
279
|
+
if "extensions" in out:
|
|
280
|
+
if not isinstance(out["extensions"], list) or len(out["extensions"]) > MAX_EXTENSIONS:
|
|
281
|
+
raise MalformedError
|
|
282
|
+
out["extensions"] = [_extension(e) for e in out["extensions"]]
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _interface(interface: Any) -> dict[str, Any]:
|
|
287
|
+
out = _known(interface, INTERFACE_FIELDS)
|
|
288
|
+
for key in INTERFACE_FIELDS & out.keys():
|
|
289
|
+
out[key] = _capped(out[key], MAX_URI)
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _card(body: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
|
|
294
|
+
"""A card in its A2A v1.0 shape, every known field as sent, or refused.
|
|
295
|
+
Unknown fields are left out, at the top level and in the nested objects."""
|
|
296
|
+
out = _known(body, CARD_FIELDS)
|
|
297
|
+
out["name"] = _capped(out.get("name"), MAX_CARD_NAME)
|
|
298
|
+
if not out["name"].strip():
|
|
299
|
+
raise MalformedError
|
|
300
|
+
for key, longest in (
|
|
301
|
+
("description", MAX_CARD_DESCRIPTION),
|
|
302
|
+
("version", MAX_CARD_VERSION),
|
|
303
|
+
("documentationUrl", MAX_URI),
|
|
304
|
+
("iconUrl", MAX_URI),
|
|
305
|
+
):
|
|
306
|
+
if key in out:
|
|
307
|
+
out[key] = _capped(out[key], longest)
|
|
308
|
+
if "skills" in out:
|
|
309
|
+
if not isinstance(out["skills"], list) or len(out["skills"]) > MAX_SKILLS:
|
|
310
|
+
raise MalformedError
|
|
311
|
+
out["skills"] = [_skill(s) for s in out["skills"]]
|
|
312
|
+
for key in ("defaultInputModes", "defaultOutputModes"):
|
|
313
|
+
if key in out:
|
|
314
|
+
out[key] = _capped_list(out[key], MAX_MODES, MAX_MODE)
|
|
315
|
+
if "capabilities" in out:
|
|
316
|
+
out["capabilities"] = _card_capabilities(out["capabilities"])
|
|
317
|
+
if "provider" in out:
|
|
318
|
+
provider = _known(out["provider"], PROVIDER_FIELDS)
|
|
319
|
+
out["provider"] = {k: _capped(v, MAX_URI) for k, v in provider.items()}
|
|
320
|
+
if "supportedInterfaces" in out:
|
|
321
|
+
interfaces = out["supportedInterfaces"]
|
|
322
|
+
if not isinstance(interfaces, list) or len(interfaces) > MAX_INTERFACES:
|
|
323
|
+
raise MalformedError
|
|
324
|
+
out["supportedInterfaces"] = [_interface(i) for i in interfaces]
|
|
325
|
+
if "signatures" in out:
|
|
326
|
+
signatures = out["signatures"]
|
|
327
|
+
if not isinstance(signatures, list) or len(signatures) > MAX_SIGNATURES:
|
|
328
|
+
raise MalformedError
|
|
329
|
+
out["signatures"] = [
|
|
330
|
+
_small(_known(s, SIGNATURE_FIELDS), MAX_OBJECT_BYTES) for s in signatures
|
|
331
|
+
]
|
|
332
|
+
for key in ("securitySchemes", "securityRequirements"):
|
|
333
|
+
if key in out:
|
|
334
|
+
out[key] = _small(out[key], MAX_OBJECT_BYTES)
|
|
335
|
+
return _small(out, MAX_CARD_BYTES)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _cancel(body: dict[str, Any]) -> dict[str, Any]:
|
|
339
|
+
out: dict[str, Any] = {"id": _word(body.get("id"), MAX_ID)}
|
|
340
|
+
if "tenant" in body:
|
|
341
|
+
out["tenant"] = _word(body["tenant"], MAX_ID)
|
|
342
|
+
if "metadata" in body:
|
|
343
|
+
if not isinstance(body["metadata"], dict):
|
|
344
|
+
raise MalformedError
|
|
345
|
+
out["metadata"] = _small(body["metadata"], MAX_METADATA_BYTES)
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
SCHEMAS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|
350
|
+
"control.leave": _empty,
|
|
351
|
+
"control.resume": _empty,
|
|
352
|
+
"control.close": _close,
|
|
353
|
+
"control.floor": _floor,
|
|
354
|
+
"control.pause": _pause,
|
|
355
|
+
"control.capabilities": _capabilities,
|
|
356
|
+
"a2a.agent_card": _card,
|
|
357
|
+
"a2a.task_cancel": _cancel,
|
|
358
|
+
}
|
|
359
|
+
# Kinds that never trigger an agent and are never surfaced as text.
|
|
360
|
+
NON_CONTENT_KINDS = frozenset(SCHEMAS) - {"a2a.task_cancel"}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def normalize(kind: str, body: dict[str, Any]) -> dict[str, Any] | None:
|
|
364
|
+
"""``body`` as its kind's schema keeps it (unknown fields stripped, card
|
|
365
|
+
text capped), or ``None`` when it is malformed: it carries content, or a
|
|
366
|
+
known field has the wrong type. Content kinds come back unchanged (their
|
|
367
|
+
parts are checked by :func:`agentconnect.limits.content_problem`)."""
|
|
368
|
+
shape = SCHEMAS.get(kind)
|
|
369
|
+
if shape is None:
|
|
370
|
+
return body
|
|
371
|
+
if _has_parts(body) or (body.keys() & CONTENT_MEMBERS):
|
|
372
|
+
return None
|
|
373
|
+
try:
|
|
374
|
+
return shape(body)
|
|
375
|
+
except (MalformedError, KeyError):
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def schema_ok(kind: str, body: dict[str, Any]) -> bool:
|
|
380
|
+
"""Whether ``body`` fits ``kind``'s schema (see :func:`normalize`)."""
|
|
381
|
+
return normalize(kind, body) is not None
|
agentconnect/shapes.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""A tiny notation for JSON shapes, shared by the docs and the tests.
|
|
2
|
+
|
|
3
|
+
A shape is one of:
|
|
4
|
+
|
|
5
|
+
* a string naming a JSON type: ``str``, ``int``, ``bool``, ``null``, ``object``
|
|
6
|
+
(any object) or ``any``; ``a|b`` allows either;
|
|
7
|
+
* a list holding one element shape (a list of that);
|
|
8
|
+
* a dict of key to shape; a key ending in ``?`` may be absent;
|
|
9
|
+
* :class:`Opt`, the inner shape or ``null``.
|
|
10
|
+
|
|
11
|
+
``scripts/gen_reference.py`` renders the shapes into the docs and the tests
|
|
12
|
+
check real output against the same objects (:func:`conforms`), so the docs
|
|
13
|
+
and the contract cannot drift apart.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
_TYPES: dict[str, type] = {"str": str, "bool": bool, "object": dict}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Opt:
|
|
26
|
+
"""The inner shape, or ``null``."""
|
|
27
|
+
|
|
28
|
+
inner: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaf(value: Any, name: str) -> bool:
|
|
32
|
+
if name == "any":
|
|
33
|
+
return True
|
|
34
|
+
if name == "null":
|
|
35
|
+
return value is None
|
|
36
|
+
if name == "int":
|
|
37
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
38
|
+
kind = _TYPES.get(name)
|
|
39
|
+
return kind is not None and isinstance(value, kind)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def conforms(value: Any, shape: Any) -> bool:
|
|
43
|
+
"""Whether ``value`` matches ``shape`` (objects must have exactly its keys)."""
|
|
44
|
+
if isinstance(shape, Opt):
|
|
45
|
+
return value is None or conforms(value, shape.inner)
|
|
46
|
+
if isinstance(shape, str):
|
|
47
|
+
return any(_leaf(value, name) for name in shape.split("|"))
|
|
48
|
+
if isinstance(shape, list):
|
|
49
|
+
return isinstance(value, list) and all(conforms(v, shape[0]) for v in value)
|
|
50
|
+
if not isinstance(shape, dict) or not isinstance(value, dict):
|
|
51
|
+
return False
|
|
52
|
+
required = {k for k in shape if not k.endswith("?")}
|
|
53
|
+
known = {k.removesuffix("?"): v for k, v in shape.items()}
|
|
54
|
+
if not required <= set(value) or not set(value) <= set(known):
|
|
55
|
+
return False
|
|
56
|
+
return all(conforms(v, known[k]) for k, v in value.items())
|
agentconnect/sockets.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Where the local API's Unix socket is bound, when its path is too long.
|
|
2
|
+
|
|
3
|
+
A Unix socket path is limited by ``sun_path``: 104 bytes on macOS and the
|
|
4
|
+
BSDs, 108 on Linux, the terminating NUL included. macOS temporary and
|
|
5
|
+
``~/Library`` paths, deep ``$XDG_DATA_HOME`` directories and long agent
|
|
6
|
+
names can exceed it. A socket whose configured path is longer than
|
|
7
|
+
:data:`MAX_SOCKET_PATH` is bound at a short path instead:
|
|
8
|
+
|
|
9
|
+
/tmp/agentconnect-<uid>/<16 hex digits of SHA-256(configured path)>.sock
|
|
10
|
+
|
|
11
|
+
The mapping is a pure function of the configured path, so the runtime that
|
|
12
|
+
binds and the CLI or SDK that connects compute the same short path from the
|
|
13
|
+
same data directory; nothing else needs to advertise it. The directory is
|
|
14
|
+
created 0700 and owned by the user, and refused when it is anything else (a
|
|
15
|
+
symlink, another owner, a group- or world-accessible mode), so the socket
|
|
16
|
+
keeps the protection of a private directory. ``/tmp`` rather than
|
|
17
|
+
``$TMPDIR``: the CLI must find the same directory from any shell, and macOS
|
|
18
|
+
gives ``$TMPDIR`` long per-session paths.
|
|
19
|
+
|
|
20
|
+
``/tmp`` cleaners such as systemd-tmpfiles remove entries that weren't used for
|
|
21
|
+
a while (10 days by default). The runtime refreshes the socket's times on
|
|
22
|
+
every connection; an agent nobody connects to for that long can lose its
|
|
23
|
+
socket (the CLI then reports it isn't running until the agent restarts).
|
|
24
|
+
Exclude the directory to avoid that, with a tmpfiles.d line
|
|
25
|
+
``x /tmp/agentconnect-*``.
|
|
26
|
+
|
|
27
|
+
Another local user can create ``/tmp/agentconnect-<uid>`` first; the runtime
|
|
28
|
+
then refuses to start with a clear error rather than use it.
|
|
29
|
+
|
|
30
|
+
Separate-OS-user mode needs the socket in its agent directory, where agents'
|
|
31
|
+
users can reach it; there the over-long path is refused at startup instead
|
|
32
|
+
(:func:`agentconnect.isolation.check_config`).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import os
|
|
39
|
+
import stat
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
# The longest path the most restrictive supported platform (macOS, 104
|
|
43
|
+
# bytes with the NUL) can bind; Linux allows 107.
|
|
44
|
+
MAX_SOCKET_PATH = 103
|
|
45
|
+
PRIVATE_DIR = 0o700
|
|
46
|
+
SHORT_ROOT = Path("/tmp") # noqa: S108 - a private 0700 directory under it, checked
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SocketPathError(OSError):
|
|
50
|
+
"""The short socket directory isn't safe to use."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def too_long(path: Path) -> bool:
|
|
54
|
+
return len(os.fsencode(path)) > MAX_SOCKET_PATH
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bind_path(path: Path) -> Path:
|
|
58
|
+
"""The path the socket configured at ``path`` is bound and reached at."""
|
|
59
|
+
if not too_long(path):
|
|
60
|
+
return path
|
|
61
|
+
# resolve(): a symlinked or relative spelling of the same directory must
|
|
62
|
+
# map to the same socket, or the CLI wouldn't find the runtime's.
|
|
63
|
+
digest = hashlib.sha256(os.fsencode(path.resolve())).hexdigest()[:16]
|
|
64
|
+
return SHORT_ROOT / f"agentconnect-{os.geteuid()}" / f"{digest}.sock"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prepare_bind_dir(socket: Path) -> None:
|
|
68
|
+
"""Create the short socket directory (0700) if needed, and check that it
|
|
69
|
+
is a real directory, ours, with no group or other access."""
|
|
70
|
+
directory = socket.parent
|
|
71
|
+
try:
|
|
72
|
+
directory.mkdir(mode=PRIVATE_DIR)
|
|
73
|
+
except FileExistsError:
|
|
74
|
+
pass # checked below; one someone else made is refused
|
|
75
|
+
else:
|
|
76
|
+
directory.chmod(PRIVATE_DIR) # a strict umask mustn't leave it 0500
|
|
77
|
+
st = directory.lstat()
|
|
78
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
79
|
+
msg = f"{directory} must be a directory, not a symlink or a file"
|
|
80
|
+
raise SocketPathError(msg)
|
|
81
|
+
if st.st_uid != os.geteuid():
|
|
82
|
+
msg = f"{directory} must be owned by this user"
|
|
83
|
+
raise SocketPathError(msg)
|
|
84
|
+
if stat.S_IMODE(st.st_mode) != PRIVATE_DIR:
|
|
85
|
+
msg = f"{directory} must have mode 0700"
|
|
86
|
+
raise SocketPathError(msg)
|