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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. 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)
@@ -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())
@@ -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)