@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.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 +124 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- 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/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py
CHANGED
|
@@ -54,7 +54,7 @@ EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observe
|
|
|
54
54
|
|
|
55
55
|
|
|
56
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
|
|
57
|
+
#: `held`: it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
|
|
58
58
|
#: cancels or reads. The contract's HOLD_SCOPES.
|
|
59
59
|
HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
|
|
60
60
|
"com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
|
|
@@ -87,7 +87,7 @@ _SAFE = 2 ** 53 - 1
|
|
|
87
87
|
|
|
88
88
|
class BoardError(Exception):
|
|
89
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
|
|
90
|
+
a write may have been stored, so send it again with the same key), `session-ended`, `not-in-scope`, `receipt-shape`,
|
|
91
91
|
`schema-unsupported`, `no-epoch`, `checkpoint`, `digest-mismatch`, `not-addressed`, `directory-invalid`,
|
|
92
92
|
`evidence`, `observation_required`. `str()` is one sentence and never carries a token or a body."""
|
|
93
93
|
|
|
@@ -264,7 +264,7 @@ def refusal_problems(value: Any) -> List[str]:
|
|
|
264
264
|
|
|
265
265
|
def directory_findings(page: Any) -> List[Dict[str, str]]:
|
|
266
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
|
|
267
|
+
within a Project, compared exactly as written (Project ids are opaque and capability names are names, not URIs,
|
|
268
268
|
so not by normal form). The finding names the second entry. Never raises."""
|
|
269
269
|
findings: List[Dict[str, str]] = []
|
|
270
270
|
projects = page.get("projects") if isinstance(page, dict) else None
|
|
@@ -366,7 +366,7 @@ def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
|
|
|
366
366
|
|
|
367
367
|
|
|
368
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
|
|
369
|
+
"""Look a Project up in one `com.directory` page (read by a Project session; a board session is not served the
|
|
370
370
|
directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
|
|
371
371
|
`BoardError("directory-invalid")` instead of answering from it. Ids and capability names compare exactly as
|
|
372
372
|
written (DEC-0077). Answers the entry, or None when the page does not list it (or lists it without `capability`):
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py
CHANGED
|
@@ -29,7 +29,6 @@ from pathlib import Path
|
|
|
29
29
|
from typing import Any, Dict, List, Optional
|
|
30
30
|
|
|
31
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
|
-
import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785
|
|
33
32
|
import fabric_estate as _estate # noqa: E402 - beside this file: passkey assertions, times, show
|
|
34
33
|
|
|
35
34
|
ENROLL_TOKEN_SCHEMA = "enroll-token/1"
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py
CHANGED
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
- ``ed25519_verify(public_key, message, signature)`` (RFC 8032) and ``is_small_order_key`` with
|
|
4
4
|
``SMALL_ORDER_ENCODINGS``: a key of small order verifies forged signatures and is refused (DEC-0045).
|
|
5
|
-
- ``is_valid_p256_key`` and ``es256_verify`` (ECDSA P-256 over SHA-256,
|
|
5
|
+
- ``is_valid_p256_key`` and ``es256_verify`` (ECDSA P-256 over SHA-256, the signature in DER) for passkeys.
|
|
6
|
+
- ``ed25519_sign`` and ``ed25519_seed_public_key`` (RFC 8032), which ``fabric_package`` signs with, over ``ed_mul_secret``.
|
|
6
7
|
- base64url without padding (``b64url``, ``b64url_bytes``, ``node_base64``) and ``sha256``.
|
|
7
8
|
|
|
8
|
-
Pure Python over integers
|
|
9
|
-
|
|
9
|
+
Pure Python over integers, slow next to OpenSSL. Verification multiplies public values only and takes the short path;
|
|
10
|
+
a secret scalar goes through ``ed_mul_secret``, a ladder that runs the same 256 steps whatever its bits are (FAA-78,
|
|
11
|
+
finding 45). Python's integers do not promise constant-time arithmetic, so that removes the branch on the secret, not
|
|
12
|
+
every timing difference: the signer stays a local, one-shot command line, never a service that signs for others.
|
|
13
|
+
Standard library only; imports no other kit module.
|
|
10
14
|
"""
|
|
11
15
|
from __future__ import annotations
|
|
12
16
|
|
|
13
17
|
import base64
|
|
14
18
|
import hashlib
|
|
15
|
-
import re
|
|
16
|
-
from typing import Any, Optional, Tuple
|
|
17
19
|
import secrets
|
|
20
|
+
from typing import Optional, Tuple
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
# The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
|
|
@@ -67,6 +70,50 @@ def _ed_mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, in
|
|
|
67
70
|
return result
|
|
68
71
|
|
|
69
72
|
|
|
73
|
+
def _ed_cswap(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int], bit: int) -> Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]]:
|
|
74
|
+
"""``(b, a)`` when ``bit`` is 1, ``(a, b)`` when it is 0, by arithmetic rather than a branch on ``bit``."""
|
|
75
|
+
deltas = [(x - y) * bit for x, y in zip(a, b)]
|
|
76
|
+
return (tuple(x - d for x, d in zip(a, deltas)), tuple(y + d for y, d in zip(b, deltas))) # type: ignore[return-value]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ed_mul_secret(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
|
|
80
|
+
"""``[scalar]point`` for a secret scalar below 2**256: a Montgomery ladder over all 256 bits with a conditional
|
|
81
|
+
swap, so every scalar takes one addition and one doubling per bit and no step depends on a bit (finding 45). The
|
|
82
|
+
addition law is complete on edwards25519, so the ladder needs no special case for the identity or a doubling."""
|
|
83
|
+
r0, r1 = (0, 1, 1, 0), point
|
|
84
|
+
for i in range(255, -1, -1):
|
|
85
|
+
bit = (scalar >> i) & 1
|
|
86
|
+
r0, r1 = _ed_cswap(r0, r1, bit)
|
|
87
|
+
r1 = _ed_add(r0, r1)
|
|
88
|
+
r0 = _ed_add(r0, r0)
|
|
89
|
+
r0, r1 = _ed_cswap(r0, r1, bit)
|
|
90
|
+
return r0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _secret_scalar(seed: bytes) -> Tuple[int, bytes]:
|
|
94
|
+
h = hashlib.sha512(seed).digest()
|
|
95
|
+
a = int.from_bytes(h[:32], "little")
|
|
96
|
+
a &= (1 << 254) - 8
|
|
97
|
+
a |= 1 << 254
|
|
98
|
+
return a, h[32:]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ed25519_seed_public_key(seed: bytes) -> bytes:
|
|
102
|
+
"""The 32-byte public key of a 32-byte Ed25519 seed (RFC 8032 §5.1.5)."""
|
|
103
|
+
return _ed_encode_point(ed_mul_secret(_secret_scalar(seed)[0], _ED_G))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ed25519_sign(seed: bytes, message: bytes) -> bytes:
|
|
107
|
+
"""A deterministic Ed25519 signature (RFC 8032 §5.1.6); the same seed and message give the bytes Node's crypto.sign
|
|
108
|
+
gives. Both secret multiplications (the key's scalar, the nonce) go through ``ed_mul_secret``."""
|
|
109
|
+
a, prefix = _secret_scalar(seed)
|
|
110
|
+
public = _ed_encode_point(ed_mul_secret(a, _ED_G))
|
|
111
|
+
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _ED_L
|
|
112
|
+
big_r = _ed_encode_point(ed_mul_secret(r, _ED_G))
|
|
113
|
+
k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _ED_L
|
|
114
|
+
return big_r + ((r + k * a) % _ED_L).to_bytes(32, "little")
|
|
115
|
+
|
|
116
|
+
|
|
70
117
|
def _ed_decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
|
|
71
118
|
if len(data) != 32:
|
|
72
119
|
return None
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -16,8 +16,8 @@ R compared by its encoding).
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
import base64
|
|
20
19
|
import hashlib
|
|
20
|
+
import http.client
|
|
21
21
|
import ipaddress
|
|
22
22
|
import json
|
|
23
23
|
import math
|
|
@@ -36,14 +36,14 @@ from pathlib import Path as _Path # noqa: E402
|
|
|
36
36
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
37
37
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
38
38
|
from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
|
|
39
|
-
SMALL_ORDER_ENCODINGS,
|
|
40
|
-
|
|
41
|
-
_ed_decode_point as _decode_point,
|
|
39
|
+
SMALL_ORDER_ENCODINGS, # noqa: F401 - public here since v0.39.1 (test/fixtures/kit-api)
|
|
40
|
+
b64url as _b64encode,
|
|
42
41
|
b64url_decode as _b64decode,
|
|
43
42
|
ed25519_verify,
|
|
44
43
|
is_small_order_key,
|
|
45
44
|
)
|
|
46
45
|
from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
|
|
46
|
+
MISSING as _MISSING,
|
|
47
47
|
jcs,
|
|
48
48
|
)
|
|
49
49
|
|
|
@@ -65,6 +65,8 @@ JWKS_MAX_AGE_SECONDS = 600
|
|
|
65
65
|
JWKS_REREAD_SECONDS = 60
|
|
66
66
|
# After a failed read, wait this long before trying again, so a down issuer does not cost every call a fetch.
|
|
67
67
|
JWKS_RETRY_SECONDS = 10
|
|
68
|
+
# One read of the key set, headers and body together, ends within this many seconds (Node's AbortSignal.timeout(5000)).
|
|
69
|
+
JWKS_FETCH_SECONDS = 5.0
|
|
68
70
|
# JavaScript's String.prototype.trim set, so a header is trimmed as the contract's verifier trims it.
|
|
69
71
|
_JS_WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
70
72
|
|
|
@@ -91,16 +93,15 @@ REFUSAL_MESSAGES = {
|
|
|
91
93
|
"unknown-state": "The state cannot be established, so the call is refused.",
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
_B64URL = re.compile(r"^[A-Za-z0-9_-]
|
|
96
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
|
|
95
97
|
# DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
|
|
96
98
|
# a URI, a jti and a scope.
|
|
97
|
-
AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})
|
|
98
|
-
OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}
|
|
99
|
-
_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S
|
|
100
|
-
_JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}
|
|
101
|
-
_SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})
|
|
99
|
+
AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?\Z")
|
|
100
|
+
OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
|
|
101
|
+
_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+\Z")
|
|
102
|
+
_JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}\Z")
|
|
103
|
+
_SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*\Z")
|
|
102
104
|
_SPEND_WINDOWS = ("total", "task", "project", "day", "week", "month")
|
|
103
|
-
_MISSING = object()
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
def _full(pattern: "re.Pattern[str]", value: Any) -> bool:
|
|
@@ -224,7 +225,8 @@ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
|
|
|
224
225
|
raw = _b64decode(jwk["x"])
|
|
225
226
|
except ValueError:
|
|
226
227
|
return {"problem": "unusable-key"}
|
|
227
|
-
|
|
228
|
+
# D4: exactly the 43 characters that write 32 bytes, so the key compared with the estate record is one string.
|
|
229
|
+
if len(raw) != 32 or _b64encode(raw) != jwk["x"]:
|
|
228
230
|
return {"problem": "unusable-key"}
|
|
229
231
|
if is_small_order_key(raw):
|
|
230
232
|
return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
|
|
@@ -379,24 +381,27 @@ def _readable_clock(now: Any) -> bool:
|
|
|
379
381
|
return False
|
|
380
382
|
|
|
381
383
|
|
|
382
|
-
def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any =
|
|
383
|
-
revocations: Any =
|
|
384
|
+
def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _MISSING,
|
|
385
|
+
revocations: Any = _MISSING) -> Optional[Tuple[str, str]]:
|
|
384
386
|
"""DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
|
|
385
387
|
token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
|
|
386
|
-
(fabric_estate.verify_estate_record) and its machine applied, or a callable returning it;
|
|
387
|
-
|
|
388
|
-
(`{"revision", "digest"?}`); `revocations` the revocation
|
|
389
|
-
|
|
390
|
-
|
|
388
|
+
(fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; only an omitted `estate`
|
|
389
|
+
skips the rule, and None (given, or returned by the callable) is an unreadable source, as Node's null is (D3).
|
|
390
|
+
`applied` is the highest revision the machine applied (`{"revision", "digest"?}`); `revocations` the revocation
|
|
391
|
+
delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. A source that
|
|
392
|
+
raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
|
|
393
|
+
if estate is _MISSING:
|
|
391
394
|
return None
|
|
392
395
|
try:
|
|
393
396
|
record = estate() if callable(estate) else estate
|
|
394
397
|
extra = revocations() if callable(revocations) else revocations
|
|
395
398
|
except Exception:
|
|
396
399
|
return ("issuer", "estate-unreadable")
|
|
400
|
+
if applied is None: # the contract's hubKeyCertified refuses a null applied mark after its record checks: unreadable
|
|
401
|
+
return ("issuer", "estate-unreadable")
|
|
397
402
|
import fabric_estate # beside this file; imported here, since fabric_estate imports this module
|
|
398
|
-
|
|
399
|
-
|
|
403
|
+
given = {} if applied is _MISSING else {"applied": applied}
|
|
404
|
+
verdict = fabric_estate.hub_key_certified(record, kid, x, now, extra_revocations=extra, **given)
|
|
400
405
|
if verdict["certified"]:
|
|
401
406
|
return None
|
|
402
407
|
# The clock was refused before row 1, so a verdict here is the token's or the trust source's.
|
|
@@ -404,8 +409,8 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = No
|
|
|
404
409
|
|
|
405
410
|
|
|
406
411
|
def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
|
|
407
|
-
issuer: Optional[str], read_only: bool = False, estate: Any =
|
|
408
|
-
estate_applied: Any =
|
|
412
|
+
issuer: Optional[str], read_only: bool = False, estate: Any = _MISSING,
|
|
413
|
+
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Dict[str, Any]:
|
|
409
414
|
"""DL-13, in its fixed order: nothing about a token is believed before its signature is.
|
|
410
415
|
`now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
|
|
411
416
|
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
|
|
@@ -525,8 +530,8 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
|
|
|
525
530
|
|
|
526
531
|
|
|
527
532
|
def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
|
|
528
|
-
jti_store: Any, issuer: Optional[str], estate: Any =
|
|
529
|
-
estate_revocations: Any =
|
|
533
|
+
jti_store: Any, issuer: Optional[str], estate: Any = _MISSING, estate_applied: Any = _MISSING,
|
|
534
|
+
estate_revocations: Any = _MISSING) -> Dict[str, Any]:
|
|
530
535
|
"""DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
|
|
531
536
|
approval-invalid; a second use is approval-reused. The jti is consumed last."""
|
|
532
537
|
if approval is None or approval == "":
|
|
@@ -624,18 +629,60 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
624
629
|
return None
|
|
625
630
|
|
|
626
631
|
|
|
627
|
-
|
|
632
|
+
def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
|
|
633
|
+
"""No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
|
|
634
|
+
issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
|
|
635
|
+
def watched(base: type) -> type:
|
|
636
|
+
class Watched(base): # type: ignore[misc, valid-type]
|
|
637
|
+
def connect(self) -> None:
|
|
638
|
+
super().connect()
|
|
639
|
+
sock = self.sock
|
|
640
|
+
|
|
641
|
+
def fire() -> None:
|
|
642
|
+
deadline["fired"] = True
|
|
643
|
+
try:
|
|
644
|
+
sock.shutdown(2) # socket.SHUT_RDWR
|
|
645
|
+
except OSError:
|
|
646
|
+
pass
|
|
647
|
+
timer = threading.Timer(max(0.0, deadline["end"] - time.monotonic()), fire)
|
|
648
|
+
timer.daemon = True
|
|
649
|
+
deadline["timer"] = timer
|
|
650
|
+
timer.start()
|
|
651
|
+
return Watched
|
|
652
|
+
|
|
653
|
+
class Http(urllib.request.HTTPHandler):
|
|
654
|
+
def http_open(self, req: Any) -> Any:
|
|
655
|
+
return self.do_open(watched(http.client.HTTPConnection), req)
|
|
656
|
+
|
|
657
|
+
class Https(urllib.request.HTTPSHandler):
|
|
658
|
+
def https_open(self, req: Any) -> Any:
|
|
659
|
+
return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
|
|
660
|
+
return urllib.request.build_opener(_NoRedirect, Http, Https)
|
|
628
661
|
|
|
629
662
|
|
|
630
663
|
def _fetch(source: str) -> Any:
|
|
631
664
|
if source.startswith(("https://", "http://")):
|
|
632
665
|
request = urllib.request.Request(source, headers={"Accept": "application/json"})
|
|
666
|
+
deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
|
|
633
667
|
try:
|
|
634
|
-
with
|
|
635
|
-
|
|
668
|
+
with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
|
|
669
|
+
body = b""
|
|
670
|
+
while len(body) <= 65536 and not deadline["fired"]:
|
|
671
|
+
chunk = response.read1(65537 - len(body))
|
|
672
|
+
if not chunk:
|
|
673
|
+
break
|
|
674
|
+
body += chunk
|
|
675
|
+
if deadline["fired"] or time.monotonic() > deadline["end"]:
|
|
676
|
+
raise TimeoutError("the key set did not arrive within %s s" % JWKS_FETCH_SECONDS)
|
|
677
|
+
return json.loads(body[:65536])
|
|
636
678
|
except urllib.error.HTTPError as exc:
|
|
637
679
|
exc.close()
|
|
638
680
|
raise
|
|
681
|
+
except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
|
|
682
|
+
raise ConnectionError(type(exc).__name__) from None
|
|
683
|
+
finally:
|
|
684
|
+
if deadline["timer"] is not None:
|
|
685
|
+
deadline["timer"].cancel()
|
|
639
686
|
return json.loads(Path(source).expanduser().read_text())
|
|
640
687
|
|
|
641
688
|
|
|
@@ -663,28 +710,37 @@ class JwksCache:
|
|
|
663
710
|
self._tried_at = -math.inf
|
|
664
711
|
self._reread_at = -math.inf # the last re-read for a kid the set did not hold
|
|
665
712
|
self._lock = threading.Lock()
|
|
666
|
-
|
|
667
|
-
def _read(self, now: float) -> None:
|
|
668
|
-
self._tried_at = now
|
|
669
|
-
try:
|
|
670
|
-
value = self._fetch(self.source)
|
|
671
|
-
except (OSError, ValueError):
|
|
672
|
-
return
|
|
673
|
-
if _has_keys(value):
|
|
674
|
-
self._keys, self._read_at = value, now
|
|
713
|
+
self._pending: Optional[threading.Event] = None
|
|
675
714
|
|
|
676
715
|
def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
716
|
+
"""The key set, or None. The read runs outside the lock (finding 13): a caller whose kid the set holds never
|
|
717
|
+
waits on another caller's fetch, and one read at a time goes out, as Node's `pending` does."""
|
|
718
|
+
leader, waiting = False, None
|
|
677
719
|
with self._lock:
|
|
678
720
|
now = self._clock()
|
|
679
721
|
fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
|
|
680
722
|
known = self._keys is not None and (kid is None or any(isinstance(k, dict) and k.get("kid") == kid for k in self._keys["keys"]))
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
self._read(now)
|
|
684
|
-
elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
|
|
723
|
+
due = (not fresh and now - self._tried_at >= JWKS_RETRY_SECONDS) or (fresh and not known and now - self._reread_at >= JWKS_REREAD_SECONDS)
|
|
724
|
+
if due and fresh:
|
|
685
725
|
self._reread_at = now
|
|
686
|
-
|
|
687
|
-
|
|
726
|
+
if due and self._pending is None:
|
|
727
|
+
self._pending, self._tried_at, leader = threading.Event(), now, True
|
|
728
|
+
elif due:
|
|
729
|
+
waiting = self._pending
|
|
730
|
+
if leader:
|
|
731
|
+
try:
|
|
732
|
+
value = self._fetch(self.source)
|
|
733
|
+
except Exception: # noqa: BLE001 - whatever a source raises leaves the set unread; the age check decides
|
|
734
|
+
value = None
|
|
735
|
+
with self._lock:
|
|
736
|
+
if _has_keys(value):
|
|
737
|
+
self._keys, self._read_at = value, now
|
|
738
|
+
done, self._pending = self._pending, None
|
|
739
|
+
done.set()
|
|
740
|
+
elif waiting is not None:
|
|
741
|
+
waiting.wait(JWKS_FETCH_SECONDS + 1)
|
|
742
|
+
with self._lock:
|
|
743
|
+
return self._keys if self._clock() - self._read_at < JWKS_MAX_AGE_SECONDS else None
|
|
688
744
|
|
|
689
745
|
|
|
690
746
|
def _kid_of(token: str) -> Optional[str]:
|
|
@@ -699,8 +755,8 @@ TIERS = ("T0", "T1", "T2")
|
|
|
699
755
|
|
|
700
756
|
|
|
701
757
|
def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
|
|
702
|
-
read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any =
|
|
703
|
-
estate_applied: Any =
|
|
758
|
+
read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = _MISSING,
|
|
759
|
+
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Callable[..., Dict[str, Any]]:
|
|
704
760
|
"""A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
|
|
705
761
|
refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
|
|
706
762
|
refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -45,7 +45,7 @@ from fabric_usage import (
|
|
|
45
45
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
48
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.2"
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -54,14 +54,14 @@ REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "
|
|
|
54
54
|
STATUSES = ("starting", "ready", "degraded", "stopping")
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})
|
|
57
|
+
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})\Z")
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
# DEC-0019: a remote placement — an online agent or dashboard — lives at an https DNS name.
|
|
61
|
-
_REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))
|
|
61
|
+
_REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?\Z")
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
_RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)
|
|
64
|
+
_RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)\Z")
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
PLACEMENTS = ("local", "remote")
|
|
@@ -213,13 +213,13 @@ CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
|
|
|
213
213
|
SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
|
|
214
214
|
|
|
215
215
|
|
|
216
|
-
_PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)
|
|
216
|
+
_PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?\Z", re.ASCII)
|
|
217
217
|
|
|
218
218
|
|
|
219
|
-
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])
|
|
219
|
+
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])\Z", re.ASCII)
|
|
220
220
|
|
|
221
221
|
|
|
222
|
-
_RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}
|
|
222
|
+
_RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z", re.ASCII)
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
def _pointer(name: str) -> str:
|
|
@@ -451,10 +451,10 @@ def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] =
|
|
|
451
451
|
return info
|
|
452
452
|
|
|
453
453
|
|
|
454
|
-
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}
|
|
454
|
+
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}\Z")
|
|
455
455
|
|
|
456
456
|
|
|
457
|
-
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)
|
|
457
|
+
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?\Z", re.ASCII)
|
|
458
458
|
|
|
459
459
|
|
|
460
460
|
def dashboard_extension_problems(value: Any) -> List[str]:
|
|
@@ -502,13 +502,13 @@ def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("sche
|
|
|
502
502
|
return {DASHBOARD_EXTENSION_KEY: value}
|
|
503
503
|
|
|
504
504
|
|
|
505
|
-
_PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}
|
|
505
|
+
_PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}\Z")
|
|
506
506
|
|
|
507
507
|
|
|
508
|
-
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}
|
|
508
|
+
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z")
|
|
509
509
|
|
|
510
510
|
|
|
511
|
-
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}
|
|
511
|
+
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}\Z")
|
|
512
512
|
|
|
513
513
|
|
|
514
514
|
def configuration_problems(doc: Any) -> List[str]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -20,7 +20,6 @@ in ``fabric_delegation``.
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import base64
|
|
23
|
-
import datetime as _dt
|
|
24
23
|
import hashlib
|
|
25
24
|
import json
|
|
26
25
|
import math
|
|
@@ -397,12 +396,13 @@ def _revision_number(value: Any) -> bool:
|
|
|
397
396
|
return _safe_integer(value) and value >= 1
|
|
398
397
|
|
|
399
398
|
|
|
400
|
-
def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any =
|
|
399
|
+
def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = _MISSING,
|
|
401
400
|
extra_revocations: Any = _MISSING) -> Dict[str, Any]:
|
|
402
401
|
"""DL-14 (DEC-0055, DEC-0060): whether ``record`` certifies the delegation key ``kid`` with the published key's ``x``
|
|
403
402
|
at ``at`` (an aware datetime or epoch seconds), for a verifier whose machine applied ``applied``
|
|
404
|
-
(``{"revision", "digest"?}
|
|
405
|
-
|
|
403
|
+
(``{"revision", "digest"?}``; ``_MISSING``, the default, when none is known; an explicit None is no revision and
|
|
404
|
+
fails closed as ``estate-unreadable``, as Node's null does, decision D3). ``extra_revocations`` are the revocation
|
|
405
|
+
delta's entries applied since the revision (DEC-0054). The contract's ``hubKeyCertified``, in its order; never raises. Answers ``{"certified": True, "machine",
|
|
406
406
|
"revision"}`` or ``{"certified": False, "fault": "token"|"estate"|"clock", "detail"}`` with detail
|
|
407
407
|
``kid-not-certified`` (the token's fault), ``estate-unreadable``, ``estate-outside-window``, ``estate-rolled-back``,
|
|
408
408
|
``estate-forked`` (the trust source's) or ``no-clock``."""
|
|
@@ -421,7 +421,7 @@ def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = Non
|
|
|
421
421
|
# A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
|
|
422
422
|
if math.isnan(issued_at) or math.isnan(valid_until) or valid_until <= issued_at or valid_until - issued_at > _MAX_WINDOW_MS:
|
|
423
423
|
return _refused("estate", "estate-unreadable")
|
|
424
|
-
if applied is not
|
|
424
|
+
if applied is not _MISSING:
|
|
425
425
|
if (not isinstance(applied, dict) or not _revision_number(_get(applied, "revision"))
|
|
426
426
|
or (_get(applied, "digest") is not _MISSING and not isinstance(_get(applied, "digest"), str))):
|
|
427
427
|
return _refused("estate", "estate-unreadable")
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py
CHANGED
|
@@ -14,6 +14,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
14
14
|
from fabric_base import (
|
|
15
15
|
ServiceError,
|
|
16
16
|
now_iso,
|
|
17
|
+
store_lock,
|
|
17
18
|
)
|
|
18
19
|
from fabric_paths import (
|
|
19
20
|
atomic_write,
|
|
@@ -32,13 +33,13 @@ EVENTS_MAX_LIMIT = 200
|
|
|
32
33
|
LEVELS = ("info", "notice", "warning", "error")
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}
|
|
36
|
+
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}\Z")
|
|
36
37
|
|
|
37
38
|
|
|
38
|
-
_TRACE_ID = re.compile(r"^(?!0{32}
|
|
39
|
+
_TRACE_ID = re.compile(r"^(?!0{32}\Z)[0-9a-f]{32}\Z")
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
_SPAN_ID = re.compile(r"^(?!0{16}
|
|
42
|
+
_SPAN_ID = re.compile(r"^(?!0{16}\Z)[0-9a-f]{16}\Z")
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
@@ -105,6 +106,7 @@ class JsonlEventLog:
|
|
|
105
106
|
def __init__(self, path: Path, keep: int = 5000):
|
|
106
107
|
self.path = Path(path)
|
|
107
108
|
self.keep = keep
|
|
109
|
+
self._lock = store_lock(self.path) # R3: appends from a threaded server's handlers never lose one another
|
|
108
110
|
|
|
109
111
|
def _all(self) -> List[Dict[str, Any]]:
|
|
110
112
|
if not self.path.exists():
|
|
@@ -118,13 +120,14 @@ class JsonlEventLog:
|
|
|
118
120
|
return out
|
|
119
121
|
|
|
120
122
|
def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
with self._lock:
|
|
124
|
+
rows = self._all()
|
|
125
|
+
next_id = int(rows[-1]["id"]) + 1 if rows else 1
|
|
126
|
+
event = make_event(next_id, now_iso(), kind, level, text, **extra)
|
|
127
|
+
rows.append(event)
|
|
128
|
+
rows = rows[-self.keep:]
|
|
129
|
+
atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
|
|
130
|
+
return event
|
|
128
131
|
|
|
129
132
|
def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
130
133
|
rows = self._all()
|
|
@@ -372,7 +372,7 @@ def request(method: str, url: str, *, headers: Optional[Mapping[str, str]] = Non
|
|
|
372
372
|
status, got_headers, got = send(method, url, dict(sent), data, left, max_bytes)
|
|
373
373
|
except HttpError:
|
|
374
374
|
raise
|
|
375
|
-
except TimeoutError:
|
|
375
|
+
except (TimeoutError, socket.timeout): # socket.timeout is no TimeoutError before Python 3.10
|
|
376
376
|
failure = HttpError("%s %s: timed out" % (method, where), url=where, attempts=attempt, kind="timeout")
|
|
377
377
|
except Exception as exc: # noqa: BLE001 - as the Node twin: whatever the transport throws is a connection error
|
|
378
378
|
failure = HttpError("%s %s: connection error (%s)" % (method, where, type(exc).__name__), url=where,
|
|
@@ -44,7 +44,7 @@ import fabric_service as _fs # noqa: E402 - beside this file: token files, atom
|
|
|
44
44
|
HUB_PROTOCOL = "fabric-hub/0.1"
|
|
45
45
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
|
46
46
|
READ_CAP = 64 * 1024
|
|
47
|
-
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(
|
|
47
|
+
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:([0-9]{4,5})")
|
|
48
48
|
TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
|
|
49
49
|
UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
50
50
|
CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
|
|
@@ -163,8 +163,12 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
|
|
|
163
163
|
if estate is not None and isinstance(doc.get("hubKey"), dict):
|
|
164
164
|
import fabric_estate # beside this file
|
|
165
165
|
key = doc["hubKey"]
|
|
166
|
+
# A missing or null applied is "none known", as fabric-hub.mjs reads it (`estate.applied ?? undefined`); only a
|
|
167
|
+
# present one is checked, and hub_key_certified refuses an explicit null of its own (decision D3).
|
|
168
|
+
applied = estate.get("applied")
|
|
166
169
|
verdict = fabric_estate.hub_key_certified(estate.get("record"), key.get("kid"), key.get("x"),
|
|
167
|
-
time.time() if now is None else now,
|
|
170
|
+
time.time() if now is None else now,
|
|
171
|
+
fabric_estate._MISSING if applied is None else applied)
|
|
168
172
|
if not verdict.get("certified"):
|
|
169
173
|
raise HubError("hub-key-refused", "The estate record does not certify the hub's key (%s)." % verdict.get("detail"),
|
|
170
174
|
data={"fault": verdict.get("fault"), "detail": verdict.get("detail")})
|