@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.1
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 +51 -0
- 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 +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +234 -19
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- 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_env.py +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a
|
|
2
|
+
usage object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule
|
|
3
|
+
and touches no file; fabric_interop builds the tools, the envelope, the job store and the MCP server on it and
|
|
4
|
+
re-exports every name here. Part of the service kit (FAA-92, split from fabric_interop; layer L3). Standard library
|
|
5
|
+
only, Python 3.9+."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import sys as _sys # noqa: E402
|
|
15
|
+
from pathlib import Path as _Path # noqa: E402
|
|
16
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
17
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
18
|
+
|
|
19
|
+
from fabric_base import ServiceError # noqa: E402
|
|
20
|
+
|
|
21
|
+
PROTOCOL = "fabric-interop/0.1"
|
|
22
|
+
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
23
|
+
MCP_REVISION = "2026-07-28"
|
|
24
|
+
# Revisions a client may name in `initialize`. MCP 2026-07-28 needs no handshake, but clients
|
|
25
|
+
# built on earlier revisions (Claude Code 2.1.285 among them) still open with one, and a server
|
|
26
|
+
# that refuses it is marked failed before tools/list is ever called.
|
|
27
|
+
HANDSHAKE_REVISIONS = ("2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28")
|
|
28
|
+
CONTRACT_VERSION = "0.1.0"
|
|
29
|
+
JOB_STATES = ("working", "input_required", "completed", "failed", "cancelled")
|
|
30
|
+
OUTCOMES = ("succeeded", "partial", "failed", "cancelled", "blocked")
|
|
31
|
+
ENVELOPE_REQUIRED = ["id", "contractVersion", "outcome", "done", "proof", "scope", "notVerified", "artifacts",
|
|
32
|
+
"createdAt", "producer", "output", "usage"]
|
|
33
|
+
# The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
|
|
34
|
+
JOB_HANDLE_SCHEMA: Dict[str, Any] = {
|
|
35
|
+
"type": "object", "required": ["job"], "additionalProperties": False,
|
|
36
|
+
"properties": {"job": {"type": "object", "required": ["id", "status"], "additionalProperties": False,
|
|
37
|
+
"properties": {"id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"},
|
|
38
|
+
"status": {"const": "working"}}}},
|
|
39
|
+
}
|
|
40
|
+
TERMINAL = ("completed", "failed", "cancelled")
|
|
41
|
+
|
|
42
|
+
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
|
|
43
|
+
_JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
|
|
44
|
+
_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
|
|
45
|
+
_SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
|
|
46
|
+
r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InteropError(ServiceError):
|
|
50
|
+
"""A rule of fabric-interop/0.1 was violated; the message is one readable sentence."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnknownJob(InteropError):
|
|
54
|
+
def __init__(self, job_id: str):
|
|
55
|
+
self.job_id = job_id
|
|
56
|
+
super().__init__("unknown-job: no job %s here." % job_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- C3.4 trace context --------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def parse_traceparent(value: Any) -> Optional[Dict[str, str]]:
|
|
62
|
+
"""W3C Trace Context Level 1, lowercase hex; version ff and all-zero ids are invalid."""
|
|
63
|
+
match = _TRACEPARENT.match(value) if isinstance(value, str) else None
|
|
64
|
+
if not match:
|
|
65
|
+
return None
|
|
66
|
+
version, trace_id, span_id, flags = match.groups()
|
|
67
|
+
if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
|
|
68
|
+
return None
|
|
69
|
+
return {"version": version, "trace_id": trace_id, "span_id": span_id, "flags": flags}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _span_id() -> str:
|
|
73
|
+
while True:
|
|
74
|
+
value = secrets.token_hex(8)
|
|
75
|
+
if set(value) != {"0"}:
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def child_traceparent(parent: Optional[str]) -> str:
|
|
80
|
+
"""A new span under `parent`; a missing or broken parent starts a new trace (W3C: restart)."""
|
|
81
|
+
parsed = parse_traceparent(parent)
|
|
82
|
+
trace_id = parsed["trace_id"] if parsed else secrets.token_hex(16)
|
|
83
|
+
flags = parsed["flags"] if parsed else "01"
|
|
84
|
+
return "00-%s-%s-%s" % (trace_id, _span_id(), flags)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def trace_ids(traceparent: Optional[str]) -> Dict[str, str]:
|
|
88
|
+
"""The `traceId`/`spanId` pair an event about this span carries (C3.4 c), or {}."""
|
|
89
|
+
parsed = parse_traceparent(traceparent)
|
|
90
|
+
return {"trace_id": parsed["trace_id"], "span_id": parsed["span_id"]} if parsed else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _usage(usage: Any, nulls: bool = False) -> Dict[str, Any]:
|
|
94
|
+
"""A usage object as fabric-interop/0.1 reads it, or with `nulls` as 0.2 does: a number nobody measured is null and
|
|
95
|
+
`partial` a boolean (DEC-0087)."""
|
|
96
|
+
if not isinstance(usage, dict):
|
|
97
|
+
raise InteropError("usage must be an object.")
|
|
98
|
+
for key in ("inputTokens", "outputTokens", "wallMs"):
|
|
99
|
+
if key not in usage:
|
|
100
|
+
raise InteropError("usage.%s is required." % key)
|
|
101
|
+
for key, value in usage.items():
|
|
102
|
+
if nulls and key == "partial":
|
|
103
|
+
if not isinstance(value, bool):
|
|
104
|
+
raise InteropError("usage.partial must be a boolean.")
|
|
105
|
+
continue
|
|
106
|
+
if key not in USAGE_NUMBERS:
|
|
107
|
+
raise InteropError("usage.%s is not a usage field." % key)
|
|
108
|
+
if nulls and value is None:
|
|
109
|
+
continue
|
|
110
|
+
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
111
|
+
if not numeric or value < 0 or (key != "costUsd" and not isinstance(value, int)):
|
|
112
|
+
raise InteropError("usage.%s must be a non-negative %s." % (key, "number" if key == "costUsd" else "integer"))
|
|
113
|
+
if nulls and usage_findings(usage, ""):
|
|
114
|
+
raise InteropError("usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).")
|
|
115
|
+
return dict(usage)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
|
|
119
|
+
# #region job-limits-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
|
|
120
|
+
# The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json). Every function
|
|
121
|
+
# here answers and never raises on malformed input.
|
|
122
|
+
|
|
123
|
+
QUOTA_EXHAUSTED = "quota-exhausted"
|
|
124
|
+
OUTCOME_UNKNOWN = "outcome-unknown"
|
|
125
|
+
LIMIT_REACHED = "limit-reached"
|
|
126
|
+
INTEROP_NULL_USAGE = "fabric-interop/0.2"
|
|
127
|
+
#: The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087).
|
|
128
|
+
PROTOCOLS_READ = ("fabric-interop/0.1", INTEROP_NULL_USAGE)
|
|
129
|
+
USAGE_NUMBERS = ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs")
|
|
130
|
+
_UTC_INSTANT = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z\Z")
|
|
131
|
+
_PAUSE = re.compile(r"^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?\Z")
|
|
132
|
+
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9-]{1,63}\Z")
|
|
133
|
+
_MAX_STATUS_MESSAGE = 500
|
|
134
|
+
_MAX_ERROR_MESSAGE = 2000
|
|
135
|
+
_ABSENT = object()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _code_points(value: str) -> int:
|
|
139
|
+
"""JSON Schema's length: code points. A Python str holds code points, a lone surrogate counting once as in JavaScript."""
|
|
140
|
+
return len(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _own(value: Any, key: str) -> Any:
|
|
144
|
+
return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def utc_time(value: Any) -> Optional[int]:
|
|
151
|
+
"""A UTC instant (`interop-common#/$defs/utcInstant`) as milliseconds since the epoch, or None: the pattern and a
|
|
152
|
+
day that exists, as the contract's strictTime reads it."""
|
|
153
|
+
match = _UTC_INSTANT.match(value) if isinstance(value, str) else None
|
|
154
|
+
if not match:
|
|
155
|
+
return None
|
|
156
|
+
year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
|
|
157
|
+
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
158
|
+
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
159
|
+
if not 1 <= month <= 12 or not 1 <= day <= days[month - 1] or hour > 23 or minute > 59:
|
|
160
|
+
return None
|
|
161
|
+
millis = int((match.group(7) or "").ljust(3, "0"))
|
|
162
|
+
return ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60000 + second * 1000 + millis
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def quota_data_ok(data: Any) -> bool:
|
|
166
|
+
"""Whether `data` is quota-exhausted's: closed, `retryable` a boolean, `resetsAt` a UTC instant, only with retryable true."""
|
|
167
|
+
retryable = _own(data, "retryable")
|
|
168
|
+
if not isinstance(data, dict) or not isinstance(retryable, bool):
|
|
169
|
+
return False
|
|
170
|
+
if any(name not in ("retryable", "resetsAt") for name in data):
|
|
171
|
+
return False
|
|
172
|
+
if "resetsAt" not in data:
|
|
173
|
+
return True
|
|
174
|
+
return retryable and utc_time(data["resetsAt"]) is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _job_of(answer: Any) -> Optional[Dict[str, Any]]:
|
|
178
|
+
job = _own(answer, "job")
|
|
179
|
+
job_id = _own(job, "id")
|
|
180
|
+
if not isinstance(job, dict) or not isinstance(job_id, str) or not job_id or _own(job, "status") not in JOB_STATES:
|
|
181
|
+
return None
|
|
182
|
+
return job
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_job_pause(answer: Any) -> Dict[str, Any]:
|
|
186
|
+
"""Whether a fabric.job.get answer ({job}) is a job a limit paused: working, its statusMessage `quota-exhausted` or
|
|
187
|
+
`quota-exhausted until <UTC instant>`, either optionally followed by `: <text>`. One that starts quota-exhausted and
|
|
188
|
+
breaks the grammar is malformed."""
|
|
189
|
+
job = _job_of(answer)
|
|
190
|
+
message = _own(job, "statusMessage")
|
|
191
|
+
if job is None or job.get("status") != "working" or not isinstance(message, str) or not message.startswith(QUOTA_EXHAUSTED):
|
|
192
|
+
return {"paused": False}
|
|
193
|
+
match = _PAUSE.match(message)
|
|
194
|
+
if not match or _code_points(message) > _MAX_STATUS_MESSAGE:
|
|
195
|
+
return {"paused": False, "malformed": True}
|
|
196
|
+
until = match.group(1)
|
|
197
|
+
if until is None:
|
|
198
|
+
return {"paused": True}
|
|
199
|
+
return {"paused": True, "resetsAt": until} if utc_time(until) is not None else {"paused": False, "malformed": True}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def job_caller_action(answer: Any, now: Any = _ABSENT) -> Dict[str, Any]:
|
|
203
|
+
"""What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`, a UTC instant: poll, wait, answer,
|
|
204
|
+
done, cancelled, resend (under the same key), person, reconcile, failed or invalid. Never "submit anew"."""
|
|
205
|
+
at = utc_time(now)
|
|
206
|
+
job = _job_of(answer)
|
|
207
|
+
if at is None or job is None:
|
|
208
|
+
return {"action": "invalid"}
|
|
209
|
+
status = job["status"]
|
|
210
|
+
if status == "working":
|
|
211
|
+
pause = read_job_pause(answer)
|
|
212
|
+
if "malformed" in pause:
|
|
213
|
+
return {"action": "invalid"}
|
|
214
|
+
if not pause["paused"]:
|
|
215
|
+
return {"action": "poll"}
|
|
216
|
+
if "resetsAt" not in pause:
|
|
217
|
+
return {"action": "wait"}
|
|
218
|
+
return {"action": "wait", "notBefore": pause["resetsAt"]} if utc_time(pause["resetsAt"]) > at else {"action": "poll"}
|
|
219
|
+
if status in ("input_required", "completed", "cancelled"):
|
|
220
|
+
return {"action": {"input_required": "answer", "completed": "done", "cancelled": "cancelled"}[status]}
|
|
221
|
+
error = _own(job, "error")
|
|
222
|
+
code = _own(error, "code")
|
|
223
|
+
message = _own(error, "message")
|
|
224
|
+
code_ok = (isinstance(code, str) and bool(_ERROR_CODE.match(code))) or _is_integer(code)
|
|
225
|
+
shape_ok = isinstance(error, dict) and all(name in ("code", "message", "data") for name in error)
|
|
226
|
+
if not shape_ok or not code_ok or not isinstance(message, str) or not message or _code_points(message) > _MAX_ERROR_MESSAGE:
|
|
227
|
+
return {"action": "invalid"}
|
|
228
|
+
if code == OUTCOME_UNKNOWN:
|
|
229
|
+
return {"action": "reconcile"}
|
|
230
|
+
if code == LIMIT_REACHED:
|
|
231
|
+
return {"action": "person"}
|
|
232
|
+
if code != QUOTA_EXHAUSTED:
|
|
233
|
+
return {"action": "failed"}
|
|
234
|
+
data = _own(error, "data")
|
|
235
|
+
if not quota_data_ok(data):
|
|
236
|
+
return {"action": "invalid"}
|
|
237
|
+
if not data["retryable"]:
|
|
238
|
+
return {"action": "person"}
|
|
239
|
+
if "resetsAt" in data and utc_time(data["resetsAt"]) > at:
|
|
240
|
+
return {"action": "resend", "notBefore": data["resetsAt"]}
|
|
241
|
+
return {"action": "resend"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_integer(value: Any) -> bool:
|
|
245
|
+
"""JavaScript's Number.isInteger for a JSON value."""
|
|
246
|
+
if isinstance(value, bool):
|
|
247
|
+
return False
|
|
248
|
+
if isinstance(value, int):
|
|
249
|
+
return True
|
|
250
|
+
return isinstance(value, float) and value == value and value not in (float("inf"), float("-inf")) and value == int(value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def intent_idempotency_key(intent: Any) -> Optional[str]:
|
|
254
|
+
"""The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
|
|
255
|
+
bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
|
|
256
|
+
for another shape or an input with no RFC 8785 form."""
|
|
257
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
258
|
+
names = ("agentId", "capability", "input", "request")
|
|
259
|
+
if not isinstance(intent, dict) or any(name not in names for name in intent):
|
|
260
|
+
return None
|
|
261
|
+
agent_id, capability, given = intent.get("agentId"), intent.get("capability"), intent.get("input")
|
|
262
|
+
if not isinstance(agent_id, str) or not agent_id or not isinstance(capability, str) or not capability or not isinstance(given, dict):
|
|
263
|
+
return None
|
|
264
|
+
form = {"agentId": agent_id, "capability": capability, "input": given}
|
|
265
|
+
if "request" in intent:
|
|
266
|
+
request = intent["request"]
|
|
267
|
+
if not isinstance(request, str) or not request or _code_points(request) > 256:
|
|
268
|
+
return None
|
|
269
|
+
form["request"] = request
|
|
270
|
+
try:
|
|
271
|
+
canonical = _fd.jcs(form)
|
|
272
|
+
except (TypeError, ValueError, RecursionError):
|
|
273
|
+
return None
|
|
274
|
+
raw = bytearray(hashlib.sha256(canonical.encode("utf-8")).digest()[:16])
|
|
275
|
+
raw[6] = (raw[6] & 0x0F) | 0x80
|
|
276
|
+
raw[8] = (raw[8] & 0x3F) | 0x80
|
|
277
|
+
hexed = raw.hex()
|
|
278
|
+
return "%s-%s-%s-%s-%s" % (hexed[:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def reads_null_usage(request_meta: Any) -> bool:
|
|
282
|
+
"""Whether a provider may write null and partial in usage toward this caller: its _meta.protocols names fabric-interop/0.2."""
|
|
283
|
+
protocols = _own(request_meta, "protocols")
|
|
284
|
+
return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
|
|
288
|
+
"""A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087)."""
|
|
289
|
+
meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
|
|
290
|
+
if parse_traceparent(traceparent):
|
|
291
|
+
meta["traceparent"] = traceparent
|
|
292
|
+
return meta
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
|
|
296
|
+
"""FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
|
|
297
|
+
if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
|
|
298
|
+
return []
|
|
299
|
+
unknown = [name for name in USAGE_NUMBERS if name in usage and usage[name] is None]
|
|
300
|
+
if not unknown:
|
|
301
|
+
return []
|
|
302
|
+
return [{"code": "FAC-SEM-119", "instancePath": "%s/partial" % at,
|
|
303
|
+
"message": "partial is false while %s is null: an unknown number makes the usage partial" % ", ".join(unknown)}]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def usage_toward(usage: Any, request_meta: Any = None) -> Dict[str, Any]:
|
|
307
|
+
"""The usage a provider writes toward a caller (the gate): toward a reader of fabric-interop/0.2, null for a number
|
|
308
|
+
nobody measured and partial true beside it; toward any other, the measured lower bound (null as 0, no partial)."""
|
|
309
|
+
if reads_null_usage(request_meta):
|
|
310
|
+
checked = _usage(usage, nulls=True)
|
|
311
|
+
if any(checked.get(name, 0) is None for name in USAGE_NUMBERS) and "partial" not in checked:
|
|
312
|
+
checked["partial"] = True
|
|
313
|
+
return checked
|
|
314
|
+
if not isinstance(usage, dict):
|
|
315
|
+
raise InteropError("usage must be an object.")
|
|
316
|
+
lower = {key: (0 if value is None else value) for key, value in usage.items() if key != "partial"}
|
|
317
|
+
return _usage(lower)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _job_findings(value: Any) -> List[Dict[str, str]]:
|
|
321
|
+
findings: List[Dict[str, str]] = []
|
|
322
|
+
if "malformed" in read_job_pause(value):
|
|
323
|
+
findings.append({"code": "FAC-SEM-119", "instancePath": "/job/statusMessage",
|
|
324
|
+
"message": "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters"})
|
|
325
|
+
error = _own(_own(value, "job"), "error")
|
|
326
|
+
data = _own(error, "data")
|
|
327
|
+
resets_at = _own(data, "resetsAt")
|
|
328
|
+
if _own(error, "code") == QUOTA_EXHAUSTED and isinstance(resets_at, str) and utc_time(resets_at) is None:
|
|
329
|
+
findings.append({"code": "FAC-SEM-119", "instancePath": "/job/error/data/resetsAt", "message": "resetsAt is a UTC instant on a day that exists"})
|
|
330
|
+
result = _own(_own(value, "job"), "result")
|
|
331
|
+
findings.extend(usage_findings(_own(result, "usage"), "/job/result/usage"))
|
|
332
|
+
return findings
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def job_limit_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
336
|
+
"""FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller, {answer, now, action}: the
|
|
337
|
+
act equals job_caller_action's). None for another kind."""
|
|
338
|
+
if kind == "interop-job":
|
|
339
|
+
return _job_findings(value)
|
|
340
|
+
if kind == "usage":
|
|
341
|
+
return usage_findings(value, "")
|
|
342
|
+
if kind == "result":
|
|
343
|
+
return usage_findings(_own(value, "usage"), "/usage")
|
|
344
|
+
if kind == "job-caller":
|
|
345
|
+
expected = job_caller_action(_own(value, "answer"), _own(value, "now"))
|
|
346
|
+
if json.dumps(_own(value, "action"), sort_keys=True, default=str) == json.dumps(expected, sort_keys=True):
|
|
347
|
+
return []
|
|
348
|
+
return [{"code": "FAC-SEM-120", "instancePath": "/action", "message": "the caller answers %s as jobCallerAction does" % json.dumps(expected, separators=(",", ":"))}]
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -> str:
|
|
353
|
+
"""A paused job's statusMessage in the pause grammar; raises InteropError for one a reader would call malformed."""
|
|
354
|
+
message = QUOTA_EXHAUSTED + ("" if resets_at is None else " until %s" % resets_at) + ("" if text is None else ": %s" % text)
|
|
355
|
+
if "malformed" in read_job_pause({"job": {"id": "x", "status": "working", "statusMessage": message}}):
|
|
356
|
+
raise InteropError("A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.")
|
|
357
|
+
return message
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def intent_digest(capability: str, arguments: Any) -> str:
|
|
361
|
+
"""What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
|
|
362
|
+
only the intent it was first sent with. An input with no RFC 8785 form is refused."""
|
|
363
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
364
|
+
try:
|
|
365
|
+
return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
|
|
366
|
+
except (TypeError, ValueError, RecursionError):
|
|
367
|
+
raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
|
|
371
|
+
"""The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
|
|
372
|
+
A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
|
|
373
|
+
retryable with its reset passed at `now`; a limit-reached job once its approval is granted; anything else, and
|
|
374
|
+
outcome-unknown above all, never."""
|
|
375
|
+
if not isinstance(job, dict) or job.get("status") != "failed":
|
|
376
|
+
return "same"
|
|
377
|
+
error = _own(job, "error")
|
|
378
|
+
code = _own(error, "code")
|
|
379
|
+
if code == LIMIT_REACHED:
|
|
380
|
+
return "run" if approval_granted is True else "same"
|
|
381
|
+
if code != QUOTA_EXHAUSTED:
|
|
382
|
+
return "same"
|
|
383
|
+
if limit_holds is not None:
|
|
384
|
+
return "run" if limit_holds is False else "same"
|
|
385
|
+
data = _own(error, "data")
|
|
386
|
+
at = utc_time(now)
|
|
387
|
+
if not quota_data_ok(data) or not data["retryable"] or at is None:
|
|
388
|
+
return "same"
|
|
389
|
+
return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
|
|
390
|
+
# #endregion job-limits-kit
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -35,10 +35,28 @@ PLUGIN_MANIFEST = ".claude-plugin/plugin.json"
|
|
|
35
35
|
SIGNATURE_STATEMENT = "fabric-package-signature/1"
|
|
36
36
|
# DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm.
|
|
37
37
|
PACKAGE_ALGORITHMS = ("EdDSA",)
|
|
38
|
-
|
|
38
|
+
from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
|
|
39
|
+
from fabric_package_schema import ( # noqa: E402,F401 - the schema reading (FAA-92)
|
|
40
|
+
_JS_DOT,
|
|
41
|
+
JS_WHITESPACE,
|
|
42
|
+
SCHEMA_PATH,
|
|
43
|
+
_SCHEMA_CACHE,
|
|
44
|
+
_schema,
|
|
45
|
+
_type_of,
|
|
46
|
+
_DATE_TIME,
|
|
47
|
+
_URI,
|
|
48
|
+
_MONTH_DAYS,
|
|
49
|
+
_leap,
|
|
50
|
+
_format_ok,
|
|
51
|
+
_PATTERNS,
|
|
52
|
+
_js_pattern,
|
|
53
|
+
_pointer,
|
|
54
|
+
_validate,
|
|
55
|
+
_problems,
|
|
56
|
+
schema_problems,
|
|
57
|
+
)
|
|
39
58
|
# What pack refuses to list: version-control and cache litter, and files that are credentials by their name. Matched
|
|
40
59
|
# whole (fullmatch) and with JavaScript's `.`, so a name ending in a newline is judged as fabric-package.mjs judges it.
|
|
41
|
-
_JS_DOT = "[^\n\r\u2028\u2029]"
|
|
42
60
|
JUNK = re.compile(r"\.git|__pycache__|\.DS_Store|%s*\.pyc" % _JS_DOT)
|
|
43
61
|
SECRET_FILE = re.compile(r"\.env(\.%s+)?|%s*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc)" % (_JS_DOT, _JS_DOT))
|
|
44
62
|
SECRET_FILE_OK = re.compile(r"\.env\.(example|sample|template)")
|
|
@@ -47,9 +65,6 @@ _PKCS8_ED25519 = bytes.fromhex("302e020100300506032b657004220420")
|
|
|
47
65
|
#: How deep a document read from disk may nest. Python's recursion limit and Node's stack give out at different depths,
|
|
48
66
|
#: so both readers refuse past this one, well below either (FAA-78, finding 43); no package document comes near it.
|
|
49
67
|
MAX_NESTING = 256
|
|
50
|
-
#: JavaScript's whitespace, what String.prototype.trim and the regular expression class \s take (ECMA-262 WhiteSpace
|
|
51
|
-
#: and LineTerminator), so a key file and a URI are read as fabric-package.mjs reads them.
|
|
52
|
-
JS_WHITESPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
53
68
|
|
|
54
69
|
|
|
55
70
|
class PackageError(Exception):
|
|
@@ -150,188 +165,6 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
|
150
165
|
return seed, kid
|
|
151
166
|
|
|
152
167
|
|
|
153
|
-
# --- the schema (bundled from the contract at the pin) ------------------------------------------------
|
|
154
|
-
|
|
155
|
-
_SCHEMA_CACHE: Dict[str, Any] = {}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _schema() -> Dict[str, Any]:
|
|
159
|
-
if "s" not in _SCHEMA_CACHE:
|
|
160
|
-
_SCHEMA_CACHE["s"] = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
161
|
-
return _SCHEMA_CACHE["s"]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _type_of(value: Any) -> str:
|
|
168
|
-
if value is None:
|
|
169
|
-
return "null"
|
|
170
|
-
if isinstance(value, bool):
|
|
171
|
-
return "boolean"
|
|
172
|
-
if isinstance(value, int) or (isinstance(value, float) and value.is_integer()):
|
|
173
|
-
return "integer"
|
|
174
|
-
if isinstance(value, float):
|
|
175
|
-
return "number"
|
|
176
|
-
if isinstance(value, str):
|
|
177
|
-
return "string"
|
|
178
|
-
if isinstance(value, list):
|
|
179
|
-
return "array"
|
|
180
|
-
return "object"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
# ASCII digits only and the whole string (fullmatch): Python's \d takes any Unicode digit and its $ a trailing newline,
|
|
184
|
-
# which JavaScript's do not (FAA-78, finding 43).
|
|
185
|
-
_DATE_TIME = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
|
|
186
|
-
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^%s]*" % JS_WHITESPACE)
|
|
187
|
-
_MONTH_DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _leap(year: int) -> bool:
|
|
191
|
-
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _format_ok(fmt: str, value: Any) -> bool:
|
|
195
|
-
if not isinstance(value, str):
|
|
196
|
-
return True
|
|
197
|
-
if fmt == "date-time":
|
|
198
|
-
m = _DATE_TIME.fullmatch(value)
|
|
199
|
-
if not m:
|
|
200
|
-
return False
|
|
201
|
-
year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
|
|
202
|
-
# The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29
|
|
203
|
-
# holds). datetime starts at year 1, so the days are counted here, as fabric-package.mjs counts them.
|
|
204
|
-
if not 1 <= month <= 12 or not 1 <= day <= (29 if month == 2 and _leap(year) else _MONTH_DAYS[month]):
|
|
205
|
-
return False
|
|
206
|
-
if hour > 23 or minute > 59 or second > 59:
|
|
207
|
-
return False
|
|
208
|
-
return m.group(8) in ("Z", "z") or (int(m.group(9)) < 24 and int(m.group(10)) < 60)
|
|
209
|
-
if fmt == "uri":
|
|
210
|
-
return bool(_URI.fullmatch(value))
|
|
211
|
-
return True
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
_PATTERNS: Dict[str, Any] = {}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _js_pattern(source: str) -> Any:
|
|
218
|
-
"""A schema's ECMAScript pattern (``u`` flag) as Python reads it the same way: ``$`` is the end of the string, not
|
|
219
|
-
the end before a final newline; ``.`` stops at JavaScript's line terminators (\\n, \\r, U+2028, U+2029); ``\\d`` and
|
|
220
|
-
``\\w`` are ASCII. Escapes and character classes are copied as written."""
|
|
221
|
-
if source not in _PATTERNS:
|
|
222
|
-
out: List[str] = []
|
|
223
|
-
i, in_class = 0, False
|
|
224
|
-
while i < len(source):
|
|
225
|
-
ch = source[i]
|
|
226
|
-
if ch == "\\":
|
|
227
|
-
out.append(source[i:i + 2])
|
|
228
|
-
i += 2
|
|
229
|
-
continue
|
|
230
|
-
if in_class:
|
|
231
|
-
in_class = ch != "]"
|
|
232
|
-
elif ch == "[":
|
|
233
|
-
in_class = True
|
|
234
|
-
elif ch == "$":
|
|
235
|
-
ch = r"\Z"
|
|
236
|
-
elif ch == ".":
|
|
237
|
-
ch = _JS_DOT
|
|
238
|
-
out.append(ch)
|
|
239
|
-
i += 1
|
|
240
|
-
_PATTERNS[source] = re.compile("".join(out), re.ASCII)
|
|
241
|
-
return _PATTERNS[source]
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _pointer(token: Any) -> str:
|
|
245
|
-
return str(token).replace("~", "~0").replace("/", "~1")
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> None:
|
|
249
|
-
"""The JSON Schema 2020-12 keywords the package schema uses, nothing more."""
|
|
250
|
-
if schema is True:
|
|
251
|
-
return
|
|
252
|
-
if schema is False:
|
|
253
|
-
out.append({"instancePath": at, "message": "is not allowed here"})
|
|
254
|
-
return
|
|
255
|
-
if "$ref" in schema:
|
|
256
|
-
target = _schema()
|
|
257
|
-
for token in schema["$ref"].lstrip("#/").split("/"):
|
|
258
|
-
target = target[token.replace("~1", "/").replace("~0", "~")]
|
|
259
|
-
_validate(target, value, at, out)
|
|
260
|
-
kind = _type_of(value)
|
|
261
|
-
if "type" in schema:
|
|
262
|
-
allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
|
263
|
-
if not (kind in allowed or (kind == "integer" and "number" in allowed)):
|
|
264
|
-
out.append({"instancePath": at, "message": "must be %s" % " or ".join(allowed)})
|
|
265
|
-
return
|
|
266
|
-
if "const" in schema and not _json_equal(schema["const"], value):
|
|
267
|
-
out.append({"instancePath": at, "message": "must be %s" % json.dumps(schema["const"])})
|
|
268
|
-
if "enum" in schema and not any(_json_equal(item, value) for item in schema["enum"]):
|
|
269
|
-
out.append({"instancePath": at, "message": "must be one of %s" % ", ".join(json.dumps(i) for i in schema["enum"])})
|
|
270
|
-
if kind == "string":
|
|
271
|
-
length = len(value) # code points, as JSON Schema counts
|
|
272
|
-
if length < schema.get("minLength", 0):
|
|
273
|
-
out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
|
|
274
|
-
if "maxLength" in schema and length > schema["maxLength"]:
|
|
275
|
-
out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
|
|
276
|
-
if "pattern" in schema and not _js_pattern(schema["pattern"]).search(value):
|
|
277
|
-
out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
|
|
278
|
-
if "format" in schema and not _format_ok(schema["format"], value):
|
|
279
|
-
out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
|
|
280
|
-
if kind in ("integer", "number") and "minimum" in schema and value < schema["minimum"]:
|
|
281
|
-
out.append({"instancePath": at, "message": "is below %s" % schema["minimum"]})
|
|
282
|
-
if kind == "array":
|
|
283
|
-
if len(value) < schema.get("minItems", 0):
|
|
284
|
-
out.append({"instancePath": at, "message": "has fewer than %d items" % schema["minItems"]})
|
|
285
|
-
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
286
|
-
out.append({"instancePath": at, "message": "has more than %d items" % schema["maxItems"]})
|
|
287
|
-
if schema.get("uniqueItems") and any(_json_equal(value[i], value[j]) for i in range(len(value)) for j in range(i)):
|
|
288
|
-
out.append({"instancePath": at, "message": "has duplicate items"})
|
|
289
|
-
if "items" in schema:
|
|
290
|
-
for index, item in enumerate(value):
|
|
291
|
-
_validate(schema["items"], item, "%s/%d" % (at, index), out)
|
|
292
|
-
if "contains" in schema:
|
|
293
|
-
matches = sum(1 for item in value if not _problems(schema["contains"], item))
|
|
294
|
-
if matches < schema.get("minContains", 1):
|
|
295
|
-
out.append({"instancePath": at, "message": "has fewer than %d matching items" % schema.get("minContains", 1)})
|
|
296
|
-
if "maxContains" in schema and matches > schema["maxContains"]:
|
|
297
|
-
out.append({"instancePath": at, "message": "has more than %d matching items" % schema["maxContains"]})
|
|
298
|
-
if kind == "object":
|
|
299
|
-
for name in schema.get("required", []):
|
|
300
|
-
if name not in value:
|
|
301
|
-
out.append({"instancePath": at, "message": "must have %s" % name})
|
|
302
|
-
properties = schema.get("properties", {})
|
|
303
|
-
for name, member in value.items():
|
|
304
|
-
if name in properties:
|
|
305
|
-
_validate(properties[name], member, "%s/%s" % (at, _pointer(name)), out)
|
|
306
|
-
elif "additionalProperties" in schema:
|
|
307
|
-
if schema["additionalProperties"] is False:
|
|
308
|
-
out.append({"instancePath": "%s/%s" % (at, _pointer(name)), "message": "is not a member of this object"})
|
|
309
|
-
else:
|
|
310
|
-
_validate(schema["additionalProperties"], member, "%s/%s" % (at, _pointer(name)), out)
|
|
311
|
-
if "propertyNames" in schema:
|
|
312
|
-
_validate(schema["propertyNames"], name, "%s/%s" % (at, _pointer(name)), out)
|
|
313
|
-
for sub in schema.get("allOf", []):
|
|
314
|
-
_validate(sub, value, at, out)
|
|
315
|
-
if "not" in schema and not _problems(schema["not"], value):
|
|
316
|
-
out.append({"instancePath": at, "message": "must not match"})
|
|
317
|
-
if "if" in schema:
|
|
318
|
-
branch = schema.get("then") if not _problems(schema["if"], value) else schema.get("else")
|
|
319
|
-
if branch is not None:
|
|
320
|
-
_validate(branch, value, at, out)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _problems(schema: Any, value: Any) -> List[Dict[str, str]]:
|
|
324
|
-
out: List[Dict[str, str]] = []
|
|
325
|
-
_validate(schema, value, "", out)
|
|
326
|
-
return out
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def schema_problems(pkg: Any) -> List[Dict[str, str]]:
|
|
330
|
-
"""Where the package breaks ``fabric-package.schema.json`` (the contract's, bundled at the pin)."""
|
|
331
|
-
return _problems(_schema(), pkg)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
# --- the contract's rules (FAC-SEM-064…066, FAC-SEM-102) ------------------------------------------------
|
|
335
168
|
|
|
336
169
|
def _objects(value: Any) -> List[Dict[str, Any]]:
|
|
337
170
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|