@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
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.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""The project board's wire (fabric-project-comms/0.1, DEC-0022, DEC-0073, DEC-0077): constants, the refusal codes, the
|
|
2
|
+
contract's request digest, the closed shapes the kit reads back (receipts, refusals, directory pages, messages) and
|
|
3
|
+
evidence. Part of the board connector (FAA-65, layer L4)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Dict
|
|
13
|
+
from typing import List
|
|
14
|
+
from typing import Optional
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
import sys as _sys # noqa: E402
|
|
18
|
+
from pathlib import Path as _Path # noqa: E402
|
|
19
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
20
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
21
|
+
import fabric_identifiers as _ids
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
COMMS_PROTOCOL = "fabric-project-comms/0.1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LEASE_SECONDS = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SUMMARY_MAX = 2000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
EVIDENCE_MAX = 8
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: The tools a board session is served (Fabric's respond scope); anything else is refused here, before it is sent.
|
|
37
|
+
RESPOND_TOOLS = frozenset(["com.status", "com.list", "com.get", "com.reply", "com.enroll", "com.claim", "com.renew",
|
|
38
|
+
"com.accept", "com.progress", "com.effect_begin", "com.complete", "com.reconcile",
|
|
39
|
+
"com.responder_replace"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorized", "authority_changed",
|
|
43
|
+
"unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
|
|
44
|
+
"idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
|
|
45
|
+
"fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
|
|
46
|
+
"cursor_reset_required", "immutable_participants", "held"])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
|
|
50
|
+
"outcome_unknown")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
|
|
57
|
+
#: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
|
|
58
|
+
#: cancels or reads. The contract's HOLD_SCOPES.
|
|
59
|
+
HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
|
|
60
|
+
"com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
|
|
61
|
+
"com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SHA256 = re.compile(r"sha256:[a-f0-9]{64}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_KEY = re.compile(r"[A-Za-z0-9._:-]{8,128}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_SAFE = 2 ** 53 - 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BoardError(Exception):
|
|
89
|
+
"""A board refusal (`code` from the contract's refusal codes) or a kit fault: `unreachable` (the hub did not answer;
|
|
90
|
+
a write may have been stored — send it again with the same key), `session-ended`, `not-in-scope`, `receipt-shape`,
|
|
91
|
+
`schema-unsupported`, `no-epoch`, `checkpoint`, `digest-mismatch`, `not-addressed`, `directory-invalid`,
|
|
92
|
+
`evidence`, `observation_required`. `str()` is one sentence and never carries a token or a body."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, code: str, message: str, retryable: bool = False, op: Optional[str] = None,
|
|
95
|
+
data: Optional[Dict[str, Any]] = None):
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
self.code, self.message, self.retryable, self.op, self.data = code, message, retryable, op, data or {}
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
return "BoardError(%r, %r)" % (self.code, self.message)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Stopped(Exception):
|
|
104
|
+
"""Raised inside a handler (from `ctx.progress` / `ctx.begin_effect`) when the attempt is no longer this
|
|
105
|
+
responder's: stop at once and act no further."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def comms_canonical(value: Any) -> str:
|
|
109
|
+
"""RFC 8785 as the board takes it: members sorted by UTF-16 code unit, integers only (|n| ≤ 2^53−1), no lone
|
|
110
|
+
surrogate, no normalisation. Raises `ValueError` on anything else rather than guessing a spelling."""
|
|
111
|
+
if value is None or isinstance(value, bool):
|
|
112
|
+
return json.dumps(value)
|
|
113
|
+
if isinstance(value, int):
|
|
114
|
+
if abs(value) > _SAFE:
|
|
115
|
+
raise ValueError("canonical JSON admits integers within ±(2^53−1) only")
|
|
116
|
+
return str(value)
|
|
117
|
+
if isinstance(value, float):
|
|
118
|
+
if not math.isfinite(value) or not value.is_integer() or abs(value) > _SAFE:
|
|
119
|
+
raise ValueError("canonical JSON admits integers within ±(2^53−1) only")
|
|
120
|
+
return str(int(value))
|
|
121
|
+
if isinstance(value, str):
|
|
122
|
+
if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
|
|
123
|
+
raise ValueError("canonical JSON refuses a lone surrogate")
|
|
124
|
+
return json.dumps(value, ensure_ascii=False)
|
|
125
|
+
if isinstance(value, (list, tuple)):
|
|
126
|
+
return "[%s]" % ",".join(comms_canonical(v) for v in value)
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
for k in value:
|
|
129
|
+
if not isinstance(k, str):
|
|
130
|
+
raise ValueError("canonical JSON has string keys only")
|
|
131
|
+
comms_canonical(k) # refuses a lone surrogate in a key too
|
|
132
|
+
keys = sorted(value, key=lambda k: k.encode("utf-16-be")) # big-endian UTF-16 bytes sort as code units
|
|
133
|
+
return "{%s}" % ",".join("%s:%s" % (comms_canonical(k), comms_canonical(value[k])) for k in keys)
|
|
134
|
+
raise ValueError("canonical JSON has no %s" % type(value).__name__)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def comms_digest(operation: str, value: Any) -> str:
|
|
138
|
+
"""`sha256:<hex>` over `fabric-project-comms/0.1 NUL <operation> NUL <canonical JSON>` (the contract's `commsDigest`)."""
|
|
139
|
+
if not isinstance(operation, str) or operation == "" or "\0" in operation:
|
|
140
|
+
raise ValueError("an operation is a non-empty name without NUL")
|
|
141
|
+
data = "%s\0%s\0%s" % (COMMS_PROTOCOL, operation, comms_canonical(value))
|
|
142
|
+
return "sha256:" + hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_int(v: Any, minimum: int) -> bool:
|
|
146
|
+
return isinstance(v, int) and not isinstance(v, bool) and minimum <= v <= _SAFE
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _closed(value: Any, at: str, required: Sequence[str], allowed: Sequence[str], problems: List[str]) -> bool:
|
|
150
|
+
if not isinstance(value, dict):
|
|
151
|
+
problems.append("%s is an object" % at)
|
|
152
|
+
return False
|
|
153
|
+
for key in required:
|
|
154
|
+
if key not in value:
|
|
155
|
+
problems.append("%s/%s is required" % (at, key))
|
|
156
|
+
for key in value:
|
|
157
|
+
if key not in allowed:
|
|
158
|
+
problems.append("%s/%s is not a member the contract names" % (at, key))
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _attempt_problems(a: Any, at: str, problems: List[str]) -> None:
|
|
163
|
+
keys = ("id", "generation", "digest", "leaseExpiresAt")
|
|
164
|
+
if _closed(a, at, keys, keys, problems):
|
|
165
|
+
if not (isinstance(a.get("id"), str) and _OPAQUE.fullmatch(a["id"])):
|
|
166
|
+
problems.append("%s/id is a board id" % at)
|
|
167
|
+
if not _is_int(a.get("generation"), 1):
|
|
168
|
+
problems.append("%s/generation is an integer ≥ 1" % at)
|
|
169
|
+
if not (isinstance(a.get("digest"), str) and _SHA256.fullmatch(a["digest"])):
|
|
170
|
+
problems.append("%s/digest is sha256:<hex>" % at)
|
|
171
|
+
if not (isinstance(a.get("leaseExpiresAt"), str) and _TIMESTAMP.fullmatch(a["leaseExpiresAt"])):
|
|
172
|
+
problems.append("%s/leaseExpiresAt is a timestamp" % at)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
|
|
176
|
+
"""`comms-receipt.schema.json` at the pin, closed at every level (a seq or a holder is refused, C3), plus what a
|
|
177
|
+
schema cannot say: `transferred` answers only `com.responder_replace` (DEC-0078) and names each request once."""
|
|
178
|
+
problems: List[str] = []
|
|
179
|
+
allowed = ("ok", "repeated", "message", "thread", "at", "request", "slot", "attempt", "transferred")
|
|
180
|
+
if not _closed(value, "", ("ok", "repeated"), allowed, problems):
|
|
181
|
+
return problems
|
|
182
|
+
if value.get("ok") is not True:
|
|
183
|
+
problems.append("/ok is true")
|
|
184
|
+
if not isinstance(value.get("repeated"), bool):
|
|
185
|
+
problems.append("/repeated is a boolean")
|
|
186
|
+
for key in ("message", "thread"):
|
|
187
|
+
if key in value and not (isinstance(value[key], str) and _OPAQUE.fullmatch(value[key])):
|
|
188
|
+
problems.append("/%s is a board id" % key)
|
|
189
|
+
if "at" in value and not (isinstance(value["at"], str) and _TIMESTAMP.fullmatch(value["at"])):
|
|
190
|
+
problems.append("/at is a timestamp")
|
|
191
|
+
if "request" in value:
|
|
192
|
+
r = value["request"]
|
|
193
|
+
keys = ("id", "state", "effect", "revision")
|
|
194
|
+
if _closed(r, "/request", keys, keys + ("settledBy",), problems):
|
|
195
|
+
if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
|
|
196
|
+
problems.append("/request/id is a board id")
|
|
197
|
+
if r.get("state") not in REQUEST_STATES:
|
|
198
|
+
problems.append("/request/state is a request state")
|
|
199
|
+
if r.get("effect") not in EFFECT_STATES:
|
|
200
|
+
problems.append("/request/effect is an effect state")
|
|
201
|
+
if not _is_int(r.get("revision"), 1):
|
|
202
|
+
problems.append("/request/revision is an integer ≥ 1")
|
|
203
|
+
if "settledBy" in r:
|
|
204
|
+
# DEC-0081: present only when the estate owner settled it; absent, the responder did.
|
|
205
|
+
if r["settledBy"] != "owner":
|
|
206
|
+
problems.append("/request/settledBy is owner")
|
|
207
|
+
if r.get("state") not in ("completed", "failed_known"):
|
|
208
|
+
problems.append("/request/settledBy names a settled request (completed or failed_known)")
|
|
209
|
+
if "slot" in value:
|
|
210
|
+
s = value["slot"]
|
|
211
|
+
keys = ("project", "capability", "generation", "leaseExpiresAt")
|
|
212
|
+
if _closed(s, "/slot", keys, keys, problems):
|
|
213
|
+
if not (isinstance(s.get("project"), str) and _PROJECT.fullmatch(s["project"])):
|
|
214
|
+
problems.append("/slot/project is a Project id")
|
|
215
|
+
if not (isinstance(s.get("capability"), str) and _CAPABILITY.fullmatch(s["capability"])):
|
|
216
|
+
problems.append("/slot/capability is a capability name")
|
|
217
|
+
if not _is_int(s.get("generation"), 1):
|
|
218
|
+
problems.append("/slot/generation is an integer ≥ 1")
|
|
219
|
+
if not (isinstance(s.get("leaseExpiresAt"), str) and _TIMESTAMP.fullmatch(s["leaseExpiresAt"])):
|
|
220
|
+
problems.append("/slot/leaseExpiresAt is a timestamp")
|
|
221
|
+
if "attempt" in value:
|
|
222
|
+
_attempt_problems(value["attempt"], "/attempt", problems)
|
|
223
|
+
if "transferred" in value:
|
|
224
|
+
t = value["transferred"]
|
|
225
|
+
if op is not None and op != "com.responder_replace":
|
|
226
|
+
problems.append("/transferred answers com.responder_replace only (DEC-0078)")
|
|
227
|
+
if not isinstance(t, list) or not 1 <= len(t) <= 64:
|
|
228
|
+
problems.append("/transferred is 1 to 64 items")
|
|
229
|
+
else:
|
|
230
|
+
seen = set()
|
|
231
|
+
for i, item in enumerate(t):
|
|
232
|
+
at = "/transferred/%d" % i
|
|
233
|
+
keys = ("request", "revision", "attempt")
|
|
234
|
+
if _closed(item, at, keys, keys, problems):
|
|
235
|
+
rid = item.get("request")
|
|
236
|
+
if not (isinstance(rid, str) and _OPAQUE.fullmatch(rid)):
|
|
237
|
+
problems.append("%s/request is a board id" % at)
|
|
238
|
+
elif rid in seen:
|
|
239
|
+
problems.append("%s/request names a request already listed (each request once, DEC-0078)" % at)
|
|
240
|
+
seen.add(rid)
|
|
241
|
+
if not _is_int(item.get("revision"), 1):
|
|
242
|
+
problems.append("%s/revision is an integer ≥ 1" % at)
|
|
243
|
+
_attempt_problems(item.get("attempt"), at + "/attempt", problems)
|
|
244
|
+
return problems
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def refusal_problems(value: Any) -> List[str]:
|
|
248
|
+
"""`comms-refusal.schema.json`: `{error: {code, message, retryable}}`, closed, a known code, one sentence ≤ 300."""
|
|
249
|
+
problems: List[str] = []
|
|
250
|
+
if _closed(value, "", ("error",), ("error",), problems):
|
|
251
|
+
e = value["error"]
|
|
252
|
+
keys = ("code", "message", "retryable")
|
|
253
|
+
if _closed(e, "/error", keys, keys, problems):
|
|
254
|
+
if e.get("code") not in REFUSAL_CODES:
|
|
255
|
+
problems.append("/error/code is a refusal code the contract names")
|
|
256
|
+
if not (isinstance(e.get("message"), str) and 1 <= len(e["message"]) <= 300):
|
|
257
|
+
problems.append("/error/message is 1 to 300 characters")
|
|
258
|
+
if not isinstance(e.get("retryable"), bool):
|
|
259
|
+
problems.append("/error/retryable is a boolean")
|
|
260
|
+
elif e.get("code") == "held" and e["retryable"] is not True:
|
|
261
|
+
problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
|
|
262
|
+
return problems
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def directory_findings(page: Any) -> List[Dict[str, str]]:
|
|
266
|
+
"""FAC-SEM-107 (DEC-0077), the contract's `commsDirectory`: a Project listed twice, or a capability listed twice
|
|
267
|
+
within a Project, compared exactly as written (Project ids are opaque and capability names are names, not URIs —
|
|
268
|
+
so not by normal form). The finding names the second entry. Never raises."""
|
|
269
|
+
findings: List[Dict[str, str]] = []
|
|
270
|
+
projects = page.get("projects") if isinstance(page, dict) else None
|
|
271
|
+
seen = set()
|
|
272
|
+
for p, project in enumerate(projects if isinstance(projects, list) else []):
|
|
273
|
+
if not isinstance(project, dict):
|
|
274
|
+
continue
|
|
275
|
+
pid = project.get("id")
|
|
276
|
+
if isinstance(pid, str):
|
|
277
|
+
if pid in seen:
|
|
278
|
+
findings.append({"code": "FAC-SEM-107", "instancePath": "/projects/%d/id" % p,
|
|
279
|
+
"message": "a directory page lists each Project once: %s is listed again" % pid})
|
|
280
|
+
seen.add(pid)
|
|
281
|
+
caps = project.get("capabilities")
|
|
282
|
+
names = set()
|
|
283
|
+
for c, entry in enumerate(caps if isinstance(caps, list) else []):
|
|
284
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("capability"), str):
|
|
285
|
+
continue
|
|
286
|
+
if entry["capability"] in names:
|
|
287
|
+
findings.append({"code": "FAC-SEM-107", "instancePath": "/projects/%d/capabilities/%d/capability" % (p, c),
|
|
288
|
+
"message": "a Project lists each capability once: %s is listed again" % entry["capability"]})
|
|
289
|
+
names.add(entry["capability"])
|
|
290
|
+
return findings
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def directory_problems(page: Any) -> List[str]:
|
|
294
|
+
"""`comms-directory.schema.json`, closed at every level (no count, holder or generation), then FAC-SEM-107."""
|
|
295
|
+
problems: List[str] = []
|
|
296
|
+
if not _closed(page, "", ("protocol", "snapshot", "projects", "cursor"), ("protocol", "snapshot", "projects", "cursor"), problems):
|
|
297
|
+
return problems
|
|
298
|
+
if page.get("protocol") != COMMS_PROTOCOL:
|
|
299
|
+
problems.append("/protocol is %s" % COMMS_PROTOCOL)
|
|
300
|
+
snap = page.get("snapshot")
|
|
301
|
+
if _closed(snap, "/snapshot", ("readAt",), ("readAt", "stale"), problems):
|
|
302
|
+
if not (isinstance(snap.get("readAt"), str) and _TIMESTAMP.fullmatch(snap["readAt"])):
|
|
303
|
+
problems.append("/snapshot/readAt is a timestamp")
|
|
304
|
+
if "stale" in snap and not isinstance(snap["stale"], bool):
|
|
305
|
+
problems.append("/snapshot/stale is a boolean")
|
|
306
|
+
if page.get("cursor") is not None and not (isinstance(page.get("cursor"), str) and _OPAQUE.fullmatch(page["cursor"])):
|
|
307
|
+
problems.append("/cursor is a board id or null")
|
|
308
|
+
projects = page.get("projects")
|
|
309
|
+
if not isinstance(projects, list) or len(projects) > 100:
|
|
310
|
+
problems.append("/projects is at most 100 Projects")
|
|
311
|
+
return problems
|
|
312
|
+
for p, project in enumerate(projects):
|
|
313
|
+
at = "/projects/%d" % p
|
|
314
|
+
if not _closed(project, at, ("id", "name", "capabilities"), ("id", "name", "capabilities"), problems):
|
|
315
|
+
continue
|
|
316
|
+
if not (isinstance(project.get("id"), str) and _PROJECT.fullmatch(project["id"])):
|
|
317
|
+
problems.append("%s/id is a Project id" % at)
|
|
318
|
+
if not (isinstance(project.get("name"), str) and 1 <= len(project["name"]) <= 120):
|
|
319
|
+
problems.append("%s/name is 1 to 120 characters" % at)
|
|
320
|
+
caps = project.get("capabilities")
|
|
321
|
+
if not isinstance(caps, list) or len(caps) > 64:
|
|
322
|
+
problems.append("%s/capabilities is at most 64 entries" % at)
|
|
323
|
+
continue
|
|
324
|
+
for c, entry in enumerate(caps):
|
|
325
|
+
cat = "%s/capabilities/%d" % (at, c)
|
|
326
|
+
if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
|
|
327
|
+
if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
|
|
328
|
+
problems.append("%s/capability is a capability name" % cat)
|
|
329
|
+
if entry.get("responder") not in ("active", "stale", "absent"):
|
|
330
|
+
problems.append("%s/responder is active, stale or absent" % cat)
|
|
331
|
+
if "held" in entry and not isinstance(entry["held"], bool):
|
|
332
|
+
problems.append("%s/held is a boolean (DEC-0079)" % cat)
|
|
333
|
+
return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
|
|
337
|
+
"""DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
|
|
338
|
+
``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
|
|
339
|
+
``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
|
|
340
|
+
raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
|
|
341
|
+
scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
|
|
342
|
+
if not scopes or not isinstance(target, dict):
|
|
343
|
+
return False
|
|
344
|
+
if target.get("repeat") is True:
|
|
345
|
+
return False
|
|
346
|
+
if tool == "com.reply" and target.get("fenced") is True:
|
|
347
|
+
return False
|
|
348
|
+
holds = holds if isinstance(holds, dict) else {}
|
|
349
|
+
|
|
350
|
+
def text(value: Any) -> Optional[str]:
|
|
351
|
+
return value if isinstance(value, str) else None
|
|
352
|
+
|
|
353
|
+
def strings(value: Any) -> set:
|
|
354
|
+
return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
|
|
355
|
+
|
|
356
|
+
thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
|
|
357
|
+
if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
|
|
358
|
+
return True
|
|
359
|
+
if "project" in scopes and project is not None and project in strings(holds.get("projects")):
|
|
360
|
+
return True
|
|
361
|
+
slots = holds.get("slots")
|
|
362
|
+
if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
|
|
363
|
+
return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
|
|
364
|
+
for slot in slots)
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
369
|
+
"""Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
|
|
370
|
+
directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
|
|
371
|
+
`BoardError("directory-invalid")` instead of answering from it. Ids and capability names compare exactly as
|
|
372
|
+
written (DEC-0077). Answers the entry, or None when the page does not list it (or lists it without `capability`):
|
|
373
|
+
an unlisted Project may still be addressable by an id the caller already holds."""
|
|
374
|
+
problems = directory_problems(page)
|
|
375
|
+
if problems:
|
|
376
|
+
raise BoardError("directory-invalid", "The directory page cannot be read: %s." % "; ".join(problems[:3]))
|
|
377
|
+
for entry in page["projects"]:
|
|
378
|
+
if entry["id"] == project_id:
|
|
379
|
+
if capability is None or any(c["capability"] == capability for c in entry["capabilities"]):
|
|
380
|
+
return entry
|
|
381
|
+
return None
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def artifact_ref(uri: str, content: Optional[bytes] = None, path: Optional[Path] = None, label: Optional[str] = None) -> Dict[str, Any]:
|
|
386
|
+
"""One artifact reference `{id, contentHash, label?}`: `id` is a URI the requester can resolve; the hash is
|
|
387
|
+
computed here from `content` or the file at `path`, never trusted from elsewhere."""
|
|
388
|
+
if (content is None) == (path is None):
|
|
389
|
+
raise BoardError("evidence", "Give the artifact's bytes or its path, one of them.")
|
|
390
|
+
digest = hashlib.sha256()
|
|
391
|
+
if content is not None:
|
|
392
|
+
digest.update(content)
|
|
393
|
+
else:
|
|
394
|
+
with open(str(path), "rb") as handle:
|
|
395
|
+
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
396
|
+
digest.update(chunk)
|
|
397
|
+
ref: Dict[str, Any] = {"id": uri, "contentHash": "sha256:" + digest.hexdigest()}
|
|
398
|
+
if label is not None:
|
|
399
|
+
ref["label"] = label
|
|
400
|
+
problems = evidence_problems([ref])
|
|
401
|
+
if problems:
|
|
402
|
+
raise BoardError("evidence", "; ".join(problems))
|
|
403
|
+
return ref
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def evidence_problems(evidence: Any) -> List[str]:
|
|
407
|
+
"""At most 8 references, each `{id: uri, contentHash: sha256:<hex>, label? ≤ 120}`, closed; and no artifact twice:
|
|
408
|
+
two ids with one normal form (DEC-0076) name one artifact."""
|
|
409
|
+
if not isinstance(evidence, list) or len(evidence) > EVIDENCE_MAX:
|
|
410
|
+
return ["evidence is a list of at most %d artifact references" % EVIDENCE_MAX]
|
|
411
|
+
problems: List[str] = []
|
|
412
|
+
for i, ref in enumerate(evidence):
|
|
413
|
+
at = "evidence/%d" % i
|
|
414
|
+
if not _closed(ref, at, ("id", "contentHash"), ("id", "contentHash", "label"), problems):
|
|
415
|
+
continue
|
|
416
|
+
if not (isinstance(ref.get("id"), str) and _URI.fullmatch(ref["id"])):
|
|
417
|
+
problems.append("%s/id is a URI" % at)
|
|
418
|
+
if not (isinstance(ref.get("contentHash"), str) and _SHA256.fullmatch(ref["contentHash"])):
|
|
419
|
+
problems.append("%s/contentHash is sha256:<hex>" % at)
|
|
420
|
+
if "label" in ref and not (isinstance(ref["label"], str) and len(ref["label"]) <= 120):
|
|
421
|
+
problems.append("%s/label is at most 120 characters" % at)
|
|
422
|
+
for j in range(i):
|
|
423
|
+
other = evidence[j]
|
|
424
|
+
if isinstance(other, dict) and _ids.same_identifier(other.get("id"), ref.get("id")):
|
|
425
|
+
problems.append("%s/id names the artifact evidence/%d already names" % (at, j))
|
|
426
|
+
return problems
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def outcome_problems(outcome: Any, effect_began: bool) -> List[str]:
|
|
430
|
+
"""`comms-complete`'s outcome: state `completed`|`failed_known`, `observed` a boolean (true after an effect began),
|
|
431
|
+
summary ≤ 2,000 characters, evidence as `evidence_problems`; plus the kit's optional `reply {text, format?,
|
|
432
|
+
artifacts?}` (≤ 65,536 UTF-8 bytes), sent with `com.reply` before the completion."""
|
|
433
|
+
problems: List[str] = []
|
|
434
|
+
if not _closed(outcome, "outcome", ("state",), ("state", "observed", "summary", "evidence", "reply"), problems):
|
|
435
|
+
return problems
|
|
436
|
+
if outcome.get("state") not in ("completed", "failed_known"):
|
|
437
|
+
problems.append("outcome/state is completed or failed_known")
|
|
438
|
+
observed = outcome.get("observed", not effect_began)
|
|
439
|
+
if not isinstance(observed, bool):
|
|
440
|
+
problems.append("outcome/observed is a boolean")
|
|
441
|
+
elif effect_began and observed is not True:
|
|
442
|
+
problems.append("observation_required: after an effect began only an observed result settles the request")
|
|
443
|
+
if "summary" in outcome and not (isinstance(outcome["summary"], str) and 1 <= len(outcome["summary"]) <= SUMMARY_MAX):
|
|
444
|
+
problems.append("outcome/summary is 1 to %d characters" % SUMMARY_MAX)
|
|
445
|
+
if "evidence" in outcome:
|
|
446
|
+
problems.extend(evidence_problems(outcome["evidence"]))
|
|
447
|
+
if "reply" in outcome:
|
|
448
|
+
reply = outcome["reply"]
|
|
449
|
+
if _closed(reply, "outcome/reply", ("text",), ("text", "format", "artifacts"), problems):
|
|
450
|
+
text = reply.get("text")
|
|
451
|
+
if not (isinstance(text, str) and text and len(text.encode("utf-8")) <= 65536):
|
|
452
|
+
problems.append("outcome/reply/text is 1 to 65,536 UTF-8 bytes")
|
|
453
|
+
if "format" in reply and reply["format"] not in ("text/plain", "text/markdown"):
|
|
454
|
+
problems.append("outcome/reply/format is text/plain or text/markdown")
|
|
455
|
+
if "artifacts" in reply:
|
|
456
|
+
problems.extend(p.replace("evidence", "outcome/reply/artifacts", 1) for p in evidence_problems(reply["artifacts"]))
|
|
457
|
+
return problems
|