@passioncode-ai/passioncode 0.2.39 → 0.2.41
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 +15 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +60 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +47 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +31 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +14 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +29 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +29 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +40 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +16 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +99 -14
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +32 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +388 -22
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +56 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +14 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +12 -7
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +8 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +60 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +10 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +39 -4
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A service's pages as Fabric Dashboards' open_link (Fabric Agent Contract DEC-0123, PL-1 to PL-4, FAC-SEM-143;
|
|
3
|
+
`src/page-link-rules.ts` at the pin): build and read the open_link, tell a raw local address, and the findings on a
|
|
4
|
+
link a document hands a person and on a URL-mode input request. A port held to the contract's answers by
|
|
5
|
+
test/fixtures/contract/page-link-vectors.json and page-link-extra-vectors.json. Part of the service kit (FAA-65,
|
|
6
|
+
layer L3).
|
|
7
|
+
|
|
8
|
+
Standard library only, Python 3.9+. The reference reads URLs with WHATWG `URL` and `URLSearchParams`; the parts of
|
|
9
|
+
that reading it relies on are written here by hand (decision D11 of the 0.51.0 brief): the host parser of an http or
|
|
10
|
+
https URL (IPv4 numbers in decimal, octal and hex and their short forms, IPv6 and its serialisation, a trailing dot,
|
|
11
|
+
percent-encoded and full-width hosts), whether a string parses as a `fabric-dashboards:` URL at all, dot-segment
|
|
12
|
+
removal with `%2e`, `application/x-www-form-urlencoded` reading and writing, JavaScript's `\\s`, the C0-and-space
|
|
13
|
+
strip at the ends and the tab-and-newline removal inside, a lone surrogate as U+FFFD after encoding, and string
|
|
14
|
+
lengths in UTF-16 code units. A host that is not ASCII after mapping is read by an approximation of UTS 46 (NFKC,
|
|
15
|
+
lower case, the ideographic full stops, the ignored characters, the Bidi rule, Punycode), which decides only whether
|
|
16
|
+
such a name is `localhost` or a name under it; a differential run against Node's URL found no difference. The credential detector is the one fabric_connections reads FAC-SEM-127 with: the contract's,
|
|
17
|
+
with ECMAScript's whitespace, `.` and case folding."""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
import sys as _sys # noqa: E402
|
|
24
|
+
from pathlib import Path as _Path # noqa: E402
|
|
25
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
26
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
from fabric_base import ServiceError, _ID, _INSTANCE # noqa: E402
|
|
29
|
+
from fabric_jsvalue import _JS_SPACE, js_keys # noqa: E402 - JavaScript's \s, and its key order
|
|
30
|
+
from fabric_secrets import raw_values # noqa: E402 - the contract's detector (DEC-0044)
|
|
31
|
+
# The detector read as the reference's ECMAScript reads it (its \s, `.` and case folding), as FAC-SEM-127 reads it.
|
|
32
|
+
from fabric_connections import _credential_name as credential_name, _secret_value as secret_value # noqa: E402
|
|
33
|
+
|
|
34
|
+
#: A service id and an instance, as `service-common.schema.json` defines them.
|
|
35
|
+
SERVICE_ID = _ID
|
|
36
|
+
INSTANCE = _INSTANCE
|
|
37
|
+
_NOT_SPACE = "[^%s]" % re.escape(_JS_SPACE)
|
|
38
|
+
#: A path on the service origin, as `service-common.schema.json` `originPath` writes it: no whitespace (JavaScript's).
|
|
39
|
+
ORIGIN_PATH = re.compile(r"^/(?!/)%s*\Z" % _NOT_SPACE)
|
|
40
|
+
#: What the host's `safePath` refuses anywhere in a path: a backslash and a C0 or DEL control character.
|
|
41
|
+
_HOST_REFUSED = re.compile(r"[\\\x00-\x1f\x7f]")
|
|
42
|
+
#: The host's limit on a path; the link around it is bounded only to keep a reader's work finite.
|
|
43
|
+
MAX_PAGE_PATH_LENGTH = 2048
|
|
44
|
+
MAX_PAGE_LINK_LENGTH = 20000
|
|
45
|
+
|
|
46
|
+
_GRAMMAR = "not fabric-dashboards://service/<id>.<instance>[?path=<path>]"
|
|
47
|
+
_PREFIX = re.compile(r"^fabric-dashboards://service/", re.I | re.A)
|
|
48
|
+
_SHAPE = re.compile(r"^fabric-dashboards://service/([^/?#]+)/?(?:\?([^#]*))?\Z")
|
|
49
|
+
_LOGIN = re.compile(r"^/fabric/+v1/+login(?:/|\Z)", re.I | re.A)
|
|
50
|
+
_DASHBOARDS_SCHEME = re.compile(r"^[%s]*fabric-dashboards:" % re.escape(_JS_SPACE), re.I | re.A)
|
|
51
|
+
#: The hosts only this computer reaches, as WHATWG URL writes them (the contract's LOOPBACK).
|
|
52
|
+
_LOOPBACK = re.compile(r"^(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\]|\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]|localhost"
|
|
53
|
+
r"|[a-z0-9.-]+\.localhost)\Z", re.I | re.A)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PageLinkError(ServiceError, ValueError):
|
|
57
|
+
"""What `dashboards_link` raises for an id, an instance or a path the grammar refuses."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- JavaScript strings ----------------------------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _js_length(text: str) -> int:
|
|
63
|
+
"""`text.length`: UTF-16 code units, so a character beyond the BMP counts twice."""
|
|
64
|
+
return len(text.encode("utf-16-le", "surrogatepass")) // 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scalars(text: str) -> str:
|
|
68
|
+
"""The string a JavaScript engine encodes: a high and a low surrogate side by side are one character, a lone one is
|
|
69
|
+
U+FFFD (what UTF-8 encoding a JavaScript string does)."""
|
|
70
|
+
if not re.search("[\ud800-\udfff]", text):
|
|
71
|
+
return text
|
|
72
|
+
return text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _lone_surrogate(text: str) -> bool:
|
|
76
|
+
"""A surrogate without its pair (the reference's LONE_SURROGATE): one is left once the pairs are joined."""
|
|
77
|
+
if not re.search("[\ud800-\udfff]", text):
|
|
78
|
+
return False
|
|
79
|
+
joined = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
|
|
80
|
+
return re.search("[\ud800-\udfff]", joined) is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- WHATWG pieces the reference relies on ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _url_input(url: str) -> str:
|
|
86
|
+
"""The basic URL parser's first steps: C0 controls and space off both ends, every tab and newline removed."""
|
|
87
|
+
return re.sub(r"[\t\n\r]", "", re.sub(r"^[\x00-\x20]+|[\x00-\x20]+\Z", "", url))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _percent_decode(data: bytes) -> bytes:
|
|
94
|
+
out = bytearray()
|
|
95
|
+
i = 0
|
|
96
|
+
while i < len(data):
|
|
97
|
+
if data[i] == 0x25 and re.match(rb"[0-9A-Fa-f]{2}", data[i + 1:i + 3]):
|
|
98
|
+
out.append(int(data[i + 1:i + 3], 16))
|
|
99
|
+
i += 3
|
|
100
|
+
else:
|
|
101
|
+
out.append(data[i])
|
|
102
|
+
i += 1
|
|
103
|
+
return bytes(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _utf8(data: bytes) -> str:
|
|
107
|
+
"""UTF-8 decode without BOM: a BOM is kept, a malformed sequence is U+FFFD."""
|
|
108
|
+
return data.decode("utf-8", "replace")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _form_parse(text: str) -> List[Tuple[str, str]]:
|
|
112
|
+
"""`application/x-www-form-urlencoded` parsing, as `URLSearchParams` does it: `&` separates, the first `=` splits,
|
|
113
|
+
`+` is a space, escapes are decoded as UTF-8."""
|
|
114
|
+
pairs = []
|
|
115
|
+
for piece in _scalars(text).encode("utf-8").split(b"&"):
|
|
116
|
+
if not piece:
|
|
117
|
+
continue
|
|
118
|
+
name, _, value = piece.partition(b"=")
|
|
119
|
+
pairs.append((_utf8(_percent_decode(name.replace(b"+", b" "))), _utf8(_percent_decode(value.replace(b"+", b" ")))))
|
|
120
|
+
return pairs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_FORM_SAFE = frozenset(b"*-._0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _form_encode(text: str) -> str:
|
|
127
|
+
"""The `application/x-www-form-urlencoded` serializer: a space is `+`, `*-._` and alphanumerics stay, the rest is
|
|
128
|
+
`%XX` (so `~` is `%7E` and `*` is kept, the other way round from urllib.parse)."""
|
|
129
|
+
return "".join(chr(b) if b in _FORM_SAFE else "+" if b == 0x20 else "%%%02X" % b for b in _scalars(text).encode("utf-8"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolved_pathname(path: str) -> str:
|
|
133
|
+
"""`new URL(path, base).pathname` for a path-absolute string with no backslash or control character: the path up to
|
|
134
|
+
its query or fragment, dot segments (`.`, `%2e`, `..`, `.%2e`, ...) removed."""
|
|
135
|
+
raw = re.split(r"[?#]", path, maxsplit=1)[0]
|
|
136
|
+
segments: List[str] = []
|
|
137
|
+
parts = raw[1:].split("/")
|
|
138
|
+
for index, segment in enumerate(parts):
|
|
139
|
+
last = index == len(parts) - 1
|
|
140
|
+
lowered = segment.lower()
|
|
141
|
+
if lowered in ("..", ".%2e", "%2e.", "%2e%2e"):
|
|
142
|
+
if segments:
|
|
143
|
+
segments.pop()
|
|
144
|
+
if last:
|
|
145
|
+
segments.append("")
|
|
146
|
+
elif lowered in (".", "%2e"):
|
|
147
|
+
if last:
|
|
148
|
+
segments.append("")
|
|
149
|
+
else:
|
|
150
|
+
segments.append(segment)
|
|
151
|
+
return "/" + "/".join(segments)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ipv4_number(text: str) -> Optional[int]:
|
|
155
|
+
if text == "":
|
|
156
|
+
return None
|
|
157
|
+
radix, digits = 10, r"[0-9]+"
|
|
158
|
+
if text[:2] in ("0x", "0X"):
|
|
159
|
+
text, radix, digits = text[2:], 16, r"[0-9A-Fa-f]+"
|
|
160
|
+
elif len(text) >= 2 and text[0] == "0":
|
|
161
|
+
text, radix, digits = text[1:], 8, r"[0-7]+"
|
|
162
|
+
if text == "":
|
|
163
|
+
return 0
|
|
164
|
+
return int(text, radix) if re.fullmatch(digits, text, re.A) else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ends_in_number(domain: str) -> bool:
|
|
168
|
+
parts = domain.split(".")
|
|
169
|
+
if parts[-1] == "":
|
|
170
|
+
if len(parts) == 1:
|
|
171
|
+
return False
|
|
172
|
+
parts.pop()
|
|
173
|
+
last = parts[-1]
|
|
174
|
+
return (last != "" and re.fullmatch(r"[0-9]+", last, re.A) is not None) or _ipv4_number(last) is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ipv4(domain: str) -> Optional[str]:
|
|
178
|
+
parts = domain.split(".")
|
|
179
|
+
if parts[-1] == "" and len(parts) > 1:
|
|
180
|
+
parts.pop()
|
|
181
|
+
if len(parts) > 4:
|
|
182
|
+
return None
|
|
183
|
+
numbers = [_ipv4_number(part) for part in parts]
|
|
184
|
+
if any(n is None for n in numbers) or any(n > 255 for n in numbers[:-1]): # type: ignore[operator]
|
|
185
|
+
return None
|
|
186
|
+
if numbers[-1] >= 256 ** (5 - len(numbers)): # type: ignore[operator]
|
|
187
|
+
return None
|
|
188
|
+
address = numbers[-1] + sum(n * 256 ** (3 - i) for i, n in enumerate(numbers[:-1])) # type: ignore[operator]
|
|
189
|
+
return ".".join(str((address >> shift) & 255) for shift in (24, 16, 8, 0))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ipv6(text: str) -> Optional[List[int]]:
|
|
193
|
+
"""The WHATWG IPv6 parser: eight pieces, or None."""
|
|
194
|
+
address = [0] * 8
|
|
195
|
+
piece, compress, i, n = 0, None, 0, len(text)
|
|
196
|
+
at = lambda k: text[k] if k < n else "" # noqa: E731
|
|
197
|
+
hexdigit = lambda c: c != "" and c in "0123456789abcdefABCDEF" # noqa: E731
|
|
198
|
+
if at(0) == ":":
|
|
199
|
+
if at(1) != ":":
|
|
200
|
+
return None
|
|
201
|
+
i, piece = 2, 1
|
|
202
|
+
compress = piece
|
|
203
|
+
while at(i) != "":
|
|
204
|
+
if piece == 8:
|
|
205
|
+
return None
|
|
206
|
+
if at(i) == ":":
|
|
207
|
+
if compress is not None:
|
|
208
|
+
return None
|
|
209
|
+
i += 1
|
|
210
|
+
piece += 1
|
|
211
|
+
compress = piece
|
|
212
|
+
continue
|
|
213
|
+
value = length = 0
|
|
214
|
+
while length < 4 and hexdigit(at(i)):
|
|
215
|
+
value = value * 16 + int(at(i), 16)
|
|
216
|
+
i += 1
|
|
217
|
+
length += 1
|
|
218
|
+
if at(i) == ".":
|
|
219
|
+
if length == 0:
|
|
220
|
+
return None
|
|
221
|
+
i -= length
|
|
222
|
+
if piece > 6:
|
|
223
|
+
return None
|
|
224
|
+
seen = 0
|
|
225
|
+
while at(i) != "":
|
|
226
|
+
v4: Optional[int] = None
|
|
227
|
+
if seen > 0:
|
|
228
|
+
if at(i) == "." and seen < 4:
|
|
229
|
+
i += 1
|
|
230
|
+
else:
|
|
231
|
+
return None
|
|
232
|
+
if at(i) == "" or at(i) not in "0123456789":
|
|
233
|
+
return None
|
|
234
|
+
while at(i) != "" and at(i) in "0123456789":
|
|
235
|
+
digit = int(at(i))
|
|
236
|
+
if v4 is None:
|
|
237
|
+
v4 = digit
|
|
238
|
+
elif v4 == 0:
|
|
239
|
+
return None
|
|
240
|
+
else:
|
|
241
|
+
v4 = v4 * 10 + digit
|
|
242
|
+
if v4 > 255:
|
|
243
|
+
return None
|
|
244
|
+
i += 1
|
|
245
|
+
address[piece] = address[piece] * 0x100 + v4 # type: ignore[operator]
|
|
246
|
+
seen += 1
|
|
247
|
+
if seen in (2, 4):
|
|
248
|
+
piece += 1
|
|
249
|
+
if seen != 4:
|
|
250
|
+
return None
|
|
251
|
+
break
|
|
252
|
+
elif at(i) == ":":
|
|
253
|
+
i += 1
|
|
254
|
+
if at(i) == "":
|
|
255
|
+
return None
|
|
256
|
+
elif at(i) != "":
|
|
257
|
+
return None
|
|
258
|
+
address[piece] = value
|
|
259
|
+
piece += 1
|
|
260
|
+
if compress is not None:
|
|
261
|
+
swaps, piece = piece - compress, 7
|
|
262
|
+
while piece != 0 and swaps > 0:
|
|
263
|
+
address[piece], address[compress + swaps - 1] = address[compress + swaps - 1], address[piece]
|
|
264
|
+
piece -= 1
|
|
265
|
+
swaps -= 1
|
|
266
|
+
elif piece != 8:
|
|
267
|
+
return None
|
|
268
|
+
return address
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _ipv6_text(address: List[int]) -> str:
|
|
272
|
+
best, best_len, run, run_len = None, 1, None, 0
|
|
273
|
+
for index, value in enumerate(address + [1]):
|
|
274
|
+
if value == 0:
|
|
275
|
+
run, run_len = (index if run is None else run), run_len + 1
|
|
276
|
+
continue
|
|
277
|
+
if run is not None and run_len > best_len:
|
|
278
|
+
best, best_len = run, run_len
|
|
279
|
+
run, run_len = None, 0
|
|
280
|
+
out, index = "", 0
|
|
281
|
+
while index < 8:
|
|
282
|
+
if index == best:
|
|
283
|
+
out += "::" if index == 0 else ":"
|
|
284
|
+
index += best_len
|
|
285
|
+
continue
|
|
286
|
+
out += "%x" % address[index] + (":" if index < 7 else "")
|
|
287
|
+
index += 1
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
_FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
292
|
+
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
293
|
+
_FULL_STOPS = re.compile("[\u3002\uff0e\uff61]")
|
|
294
|
+
#: Characters UTS 46 maps to nothing.
|
|
295
|
+
_IGNORED = re.compile("[\u00ad\u034f\u180b-\u180d\u180f\u200b\u2060\u2064\ufe00-\ufe0f\ufeff\U0001bca0-\U0001bca3"
|
|
296
|
+
"\U000e0100-\U000e01ef]")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _label_ok(label: str) -> bool:
|
|
300
|
+
"""A label UTS 46 keeps: no control, unassigned, private-use or space character, no U+FFFD, no leading mark."""
|
|
301
|
+
if label and unicodedata.category(label[0]).startswith("M"):
|
|
302
|
+
return False
|
|
303
|
+
return all(c != "\ufffd" and unicodedata.category(c) not in ("Cc", "Cf", "Cs", "Co", "Cn", "Zs", "Zl", "Zp") for c in label)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _domain_to_ascii(domain: str) -> Optional[str]:
|
|
307
|
+
"""Domain to ASCII with beStrict false, as the reference's `URL` (Node's) does it: an ASCII name is lower cased,
|
|
308
|
+
an `xn--` label in it included, unchecked; a name that is not ASCII goes through UTS 46 processing, approximated
|
|
309
|
+
(module docstring), where an `xn--` label must decode to a valid label."""
|
|
310
|
+
if domain.isascii():
|
|
311
|
+
return domain.lower()
|
|
312
|
+
stops = _IGNORED.sub("", _FULL_STOPS.sub(".", domain))
|
|
313
|
+
if any(ord(c) > 0x7F and "." in unicodedata.normalize("NFKC", c) for c in stops):
|
|
314
|
+
return None # a character that maps to a dot (one dot leader, small full stop, ...) is disallowed
|
|
315
|
+
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", stops).lower())
|
|
316
|
+
labels = [] # each label in Unicode, the form UTS 46 checks
|
|
317
|
+
for label in mapped.split("."):
|
|
318
|
+
if label[:4] == "xn--":
|
|
319
|
+
try:
|
|
320
|
+
decoded = label[4:].encode("ascii").decode("punycode")
|
|
321
|
+
except (UnicodeError, ValueError):
|
|
322
|
+
return None
|
|
323
|
+
if decoded == "" or decoded.isascii() or unicodedata.normalize("NFC", decoded) != decoded:
|
|
324
|
+
return None
|
|
325
|
+
label = decoded
|
|
326
|
+
if not _label_ok(label):
|
|
327
|
+
return None
|
|
328
|
+
labels.append(label)
|
|
329
|
+
if not _bidi_ok(labels):
|
|
330
|
+
return None
|
|
331
|
+
result = ".".join(label if label.isascii() else "xn--" + label.encode("punycode").decode("ascii") for label in labels)
|
|
332
|
+
return result or None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_RTL = ("R", "AL", "AN")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _bidi_ok(labels: List[str]) -> bool:
|
|
339
|
+
"""CheckBidi (RFC 5893 section 2): in a name with a right-to-left label, every label keeps the Bidi rule."""
|
|
340
|
+
classes = [[unicodedata.bidirectional(c) for c in label] for label in labels]
|
|
341
|
+
if not any(c in _RTL for label in classes for c in label):
|
|
342
|
+
return True
|
|
343
|
+
for label in classes:
|
|
344
|
+
if not label:
|
|
345
|
+
continue
|
|
346
|
+
if label[0] not in ("L", "R", "AL"):
|
|
347
|
+
return False
|
|
348
|
+
core = list(label)
|
|
349
|
+
while len(core) > 1 and core[-1] == "NSM":
|
|
350
|
+
core.pop()
|
|
351
|
+
if label[0] == "L":
|
|
352
|
+
allowed, ends = ("L", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"), ("L", "EN")
|
|
353
|
+
else:
|
|
354
|
+
allowed, ends = ("R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"), ("R", "AL", "EN", "AN")
|
|
355
|
+
if "EN" in label and "AN" in label:
|
|
356
|
+
return False
|
|
357
|
+
if any(c not in allowed for c in label) or core[-1] not in ends:
|
|
358
|
+
return False
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _special_host(text: str) -> Optional[str]:
|
|
363
|
+
"""The host parser for an http or https URL: the serialised host, or None (failure)."""
|
|
364
|
+
if text.startswith("["):
|
|
365
|
+
if not text.endswith("]"):
|
|
366
|
+
return None
|
|
367
|
+
address = _ipv6(text[1:-1])
|
|
368
|
+
return None if address is None else "[%s]" % _ipv6_text(address)
|
|
369
|
+
domain = _utf8(_percent_decode(_scalars(text).encode("utf-8")))
|
|
370
|
+
ascii_domain = _domain_to_ascii(domain)
|
|
371
|
+
if ascii_domain is None or any(c in _FORBIDDEN_DOMAIN for c in ascii_domain):
|
|
372
|
+
return None
|
|
373
|
+
return _ipv4(ascii_domain) if _ends_in_number(ascii_domain) else ascii_domain
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _host_and_port(authority: str) -> Optional[Tuple[str, str]]:
|
|
377
|
+
"""The host state: the host up to a `:` outside brackets, and the port after it (digits, at most 65535)."""
|
|
378
|
+
host, inside, port = "", False, None
|
|
379
|
+
for index, c in enumerate(authority):
|
|
380
|
+
if c == ":" and not inside:
|
|
381
|
+
port = authority[index + 1:]
|
|
382
|
+
break
|
|
383
|
+
inside = True if c == "[" else False if c == "]" else inside
|
|
384
|
+
host += c
|
|
385
|
+
if port is not None:
|
|
386
|
+
if host == "" or (port != "" and (not re.fullmatch(r"[0-9]+", port, re.A) or int(port) > 65535)):
|
|
387
|
+
return None
|
|
388
|
+
return host, port or ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _http_hostname(url: str) -> Optional[str]:
|
|
392
|
+
"""`new URL(url).hostname` when the URL parses with scheme http or https; None otherwise."""
|
|
393
|
+
text = _url_input(url)
|
|
394
|
+
scheme = _SCHEME.match(text)
|
|
395
|
+
if not scheme or scheme.group(1).lower() not in ("http", "https"):
|
|
396
|
+
return None
|
|
397
|
+
rest = text[scheme.end():].lstrip("/\\") # the special authority (ignore) slashes states
|
|
398
|
+
authority = re.split(r"[/\\?#]", rest, maxsplit=1)[0]
|
|
399
|
+
if "@" in authority:
|
|
400
|
+
authority = authority[authority.rfind("@") + 1:]
|
|
401
|
+
if authority == "":
|
|
402
|
+
return None
|
|
403
|
+
split = _host_and_port(authority)
|
|
404
|
+
if split is None or split[0] == "":
|
|
405
|
+
return None
|
|
406
|
+
return _special_host(split[0])
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _parses_as_dashboards(url: str) -> bool:
|
|
410
|
+
"""`new URL(url).protocol === "fabric-dashboards:"`: the scheme, then the authority of a non-special URL (an opaque
|
|
411
|
+
host, or an IPv6 address in brackets, and a port) is the only part that can fail."""
|
|
412
|
+
text = _url_input(url)
|
|
413
|
+
scheme = _SCHEME.match(text)
|
|
414
|
+
if not scheme or scheme.group(1).lower() != "fabric-dashboards":
|
|
415
|
+
return False
|
|
416
|
+
rest = text[scheme.end():]
|
|
417
|
+
if not rest.startswith("//"):
|
|
418
|
+
return True # a path or an opaque path
|
|
419
|
+
authority = re.split(r"[/?#]", rest[2:], maxsplit=1)[0]
|
|
420
|
+
if "@" in authority:
|
|
421
|
+
authority = authority[authority.rfind("@") + 1:]
|
|
422
|
+
if authority == "":
|
|
423
|
+
return False
|
|
424
|
+
split = _host_and_port(authority)
|
|
425
|
+
if split is None:
|
|
426
|
+
return False
|
|
427
|
+
host = split[0]
|
|
428
|
+
if host.startswith("["):
|
|
429
|
+
return host.endswith("]") and _ipv6(host[1:-1]) is not None
|
|
430
|
+
return not any(c in _FORBIDDEN_HOST for c in host)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --- the contract's rules --------------------------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def dashboards_link(service: str, instance: str, path: Optional[str] = None) -> str:
|
|
436
|
+
"""The open_link for the service `service.instance` (PL-1): its dashboard without `path`, or the page at `path` on
|
|
437
|
+
its origin, encoded as Fabric Dashboards' `serviceLink` encodes it. Raises PageLinkError for what the grammar
|
|
438
|
+
refuses, naming every problem."""
|
|
439
|
+
problems = _link_problems(service, instance, path)
|
|
440
|
+
if problems:
|
|
441
|
+
raise PageLinkError("; ".join(problems))
|
|
442
|
+
base = "fabric-dashboards://service/%s.%s" % (service, instance)
|
|
443
|
+
return base if path is None or path == "" else "%s?path=%s" % (base, _form_encode(path))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def parse_dashboards_link(url: str) -> Dict[str, Any]:
|
|
447
|
+
"""Read an open_link: {"ok": True, "service", "instance", "path"?} or {"ok": False, "problems": [...]}."""
|
|
448
|
+
if not isinstance(url, str):
|
|
449
|
+
return {"ok": False, "problems": [_GRAMMAR]}
|
|
450
|
+
if _js_length(url) > MAX_PAGE_LINK_LENGTH:
|
|
451
|
+
return {"ok": False, "problems": ["an open_link is at most %d characters" % MAX_PAGE_LINK_LENGTH]}
|
|
452
|
+
# The scheme and the host's verb are read without regard to case, as the host reads them.
|
|
453
|
+
link = _PREFIX.sub("fabric-dashboards://service/", url, count=1)
|
|
454
|
+
match = _SHAPE.match(link)
|
|
455
|
+
if not match:
|
|
456
|
+
return {"ok": False, "problems": [_GRAMMAR]}
|
|
457
|
+
key = match.group(1)
|
|
458
|
+
service, dot, instance = key.partition(".")
|
|
459
|
+
if not match.group(2):
|
|
460
|
+
problems = _link_problems(service, instance)
|
|
461
|
+
return {"ok": False, "problems": problems} if problems else {"ok": True, "service": service, "instance": instance}
|
|
462
|
+
# The parameters as the host's URL parser reads them: the query is the link's end, so the parser's strip of C0
|
|
463
|
+
# and space at the end and its removal of tabs and newlines reach it; a second `?` stays part of a name.
|
|
464
|
+
query = _form_parse(_url_input(match.group(2)))
|
|
465
|
+
paths = [value for name, value in query if name == "path"]
|
|
466
|
+
if len(paths) != 1:
|
|
467
|
+
return {"ok": False, "problems": ["path is given more than once" if paths else "an open_link names its path"]}
|
|
468
|
+
if any(name != "path" for name, _ in query):
|
|
469
|
+
return {"ok": False, "problems": ["an open_link carries its path and nothing else"]}
|
|
470
|
+
path = paths[0]
|
|
471
|
+
if path == "":
|
|
472
|
+
return {"ok": False, "problems": ["the path is empty; a link to the dashboard itself names no path"]}
|
|
473
|
+
problems = _link_problems(service, instance, path)
|
|
474
|
+
return {"ok": False, "problems": problems} if problems else {"ok": True, "service": service, "instance": instance, "path": path}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _link_problems(service: Any, instance: Any, path: Any = None) -> List[str]:
|
|
478
|
+
problems = []
|
|
479
|
+
if not isinstance(service, str) or not SERVICE_ID.match(service):
|
|
480
|
+
problems.append("the service is not a service id")
|
|
481
|
+
if not isinstance(instance, str) or not INSTANCE.match(instance):
|
|
482
|
+
problems.append("the instance is not an instance name")
|
|
483
|
+
if path is None or path == "":
|
|
484
|
+
return problems
|
|
485
|
+
if not isinstance(path, str):
|
|
486
|
+
problems.append("the path is not a path on the service origin")
|
|
487
|
+
elif _js_length(path) > MAX_PAGE_PATH_LENGTH:
|
|
488
|
+
problems.append("the path is at most %d characters" % MAX_PAGE_PATH_LENGTH)
|
|
489
|
+
elif not ORIGIN_PATH.match(path) or _HOST_REFUSED.search(path) or _lone_surrogate(path):
|
|
490
|
+
# The reference also asks that `new URL(path, base)` keeps the base's origin: a path-absolute string with no
|
|
491
|
+
# backslash or control character always does, so the test is the three above.
|
|
492
|
+
problems.append("the path is not a path on the service origin")
|
|
493
|
+
elif _path_carries_credential(path):
|
|
494
|
+
problems.append("the path could carry a token or a login code; signing in is the host's")
|
|
495
|
+
return problems
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _path_carries_credential(path: str) -> bool:
|
|
499
|
+
"""A login-code route, or a query or fragment piece that could carry a credential (FL-7: a link carries none)."""
|
|
500
|
+
path = _scalars(path) # a surrogate pair is one character here, as in JavaScript (a lone one was refused)
|
|
501
|
+
if _LOGIN.match(_resolved_pathname(path)): # dot segments and letter case do not hide the route
|
|
502
|
+
return True
|
|
503
|
+
# The query and the fragment, a hash route's own query (`#/jobs?token=`) included, as pieces of one query.
|
|
504
|
+
rest = re.sub(r"[#?]", "&", re.sub(r"^[?#]", "", re.sub(r"^[^?#]*", "", path, count=1), count=1))
|
|
505
|
+
if not rest:
|
|
506
|
+
return False
|
|
507
|
+
raw = [value for values in raw_values(rest).values() for value in values]
|
|
508
|
+
return (secret_value(rest) or any(secret_value(value) for value in raw)
|
|
509
|
+
or any((value != "" and credential_name(name)) or secret_value(value) for name, value in _form_parse(rest)))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def is_loopback_page_url(url: Any) -> bool:
|
|
513
|
+
"""True for an `http` or `https` address on a loopback host: a local service's raw address."""
|
|
514
|
+
if not isinstance(url, str):
|
|
515
|
+
return False
|
|
516
|
+
hostname = _http_hostname(url)
|
|
517
|
+
return hostname is not None and _LOOPBACK.match(re.sub(r"\.\Z", "", hostname)) is not None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _finding(instance_path: str, message: str) -> Dict[str, str]:
|
|
521
|
+
return {"code": "FAC-SEM-143", "instancePath": instance_path, "message": message}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def page_link_findings(url: Any, instance_path: str, label: str) -> List[Dict[str, str]]:
|
|
525
|
+
"""FAC-SEM-143 for one link a document hands a person: a raw loopback address, or an open_link outside its
|
|
526
|
+
grammar. A link is taken for an open_link behind JavaScript's whitespace, or as a URL parser sees it (C0 dropped
|
|
527
|
+
around it, a tab or newline inside the scheme), and read as written."""
|
|
528
|
+
if not isinstance(url, str):
|
|
529
|
+
return []
|
|
530
|
+
if is_loopback_page_url(url):
|
|
531
|
+
return [_finding(instance_path, "%s hands a person a raw local address; give Fabric Dashboards' open_link (DEC-0123 PL-1)" % label)]
|
|
532
|
+
bare = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+\Z", "", url)
|
|
533
|
+
if _DASHBOARDS_SCHEME.match(url) or _parses_as_dashboards(url):
|
|
534
|
+
parsed = parse_dashboards_link(bare)
|
|
535
|
+
if not parsed["ok"]:
|
|
536
|
+
return [_finding(instance_path, "%s is not an open_link: %s (DEC-0123 PL-1)" % (label, "; ".join(parsed["problems"])))]
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def input_request_link_findings(value: Any) -> List[Dict[str, str]]:
|
|
541
|
+
"""FAC-SEM-143 on an input request in URL mode (kind `interop-input-requests`): its `url` is a link a person opens."""
|
|
542
|
+
requests = value.get("inputRequests") if isinstance(value, dict) else None
|
|
543
|
+
if not isinstance(requests, dict):
|
|
544
|
+
return []
|
|
545
|
+
findings: List[Dict[str, str]] = []
|
|
546
|
+
for key in js_keys(requests):
|
|
547
|
+
request = requests[key]
|
|
548
|
+
params = request.get("params") if isinstance(request, dict) else None
|
|
549
|
+
if isinstance(params, dict) and params.get("mode") == "url":
|
|
550
|
+
findings += page_link_findings(params.get("url"), "/inputRequests/%s/params/url" % key, "input request %s" % key)
|
|
551
|
+
return findings
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def page_link_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
555
|
+
"""Kind `page-link` (`{url}`): a link a service, its tools or its messages hand a person, such as a tool answer's
|
|
556
|
+
`open_link`. None for another kind."""
|
|
557
|
+
if kind != "page-link":
|
|
558
|
+
return None
|
|
559
|
+
return page_link_findings(value.get("url") if isinstance(value, dict) else None, "/url", "the link")
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py
CHANGED
|
@@ -14,6 +14,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
14
14
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
15
15
|
from fabric_base import (
|
|
16
16
|
WINDOWS,
|
|
17
|
+
ServiceError,
|
|
18
|
+
_INSTANCE,
|
|
17
19
|
_require_id,
|
|
18
20
|
)
|
|
19
21
|
from fabric_platform import (
|
|
@@ -55,6 +57,25 @@ def service_dirs(service_id: str) -> Dict[str, Path]:
|
|
|
55
57
|
return {"data": data, "logs": state / "logs", "cache": cache}
|
|
56
58
|
|
|
57
59
|
|
|
60
|
+
def instance_dirs(service_id: str, instance: str = "default") -> Dict[str, Path]:
|
|
61
|
+
"""One instance's folders (FAA-125, brief D16): the ``default`` instance keeps ``service_dirs`` (an existing install
|
|
62
|
+
keeps its data, logs and lock); any other is a sibling named as its descriptor, ``<id>.<instance>``, never inside
|
|
63
|
+
the default tree, so one instance's purge, prune or backup never reaches another's."""
|
|
64
|
+
_require_id(service_id)
|
|
65
|
+
if not isinstance(instance, str) or not _INSTANCE.match(instance):
|
|
66
|
+
raise ServiceError("Instance %r must match %s." % (instance, _INSTANCE.pattern))
|
|
67
|
+
if instance == "default":
|
|
68
|
+
return service_dirs(service_id)
|
|
69
|
+
folder, personal = "%s.%s" % (service_id, instance), service_dirs(service_id)
|
|
70
|
+
if sys.platform == "win32":
|
|
71
|
+
data = personal["data"].parent / folder
|
|
72
|
+
return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
|
|
73
|
+
if sys.platform == "darwin":
|
|
74
|
+
return {key: path.parent / folder for key, path in personal.items()}
|
|
75
|
+
return {"data": personal["data"].parent / folder, "logs": personal["logs"].parent.parent / folder / "logs",
|
|
76
|
+
"cache": personal["cache"].parent / folder}
|
|
77
|
+
|
|
78
|
+
|
|
58
79
|
def _read_at(fd: int, n: int, offset: int) -> bytes:
|
|
59
80
|
"""`os.pread` where it exists; Windows has none, so seek and read (the descriptor's appends
|
|
60
81
|
still go to the end: O_APPEND moves to it before every write)."""
|
|
@@ -19,7 +19,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
|
19
19
|
"fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
|
|
20
20
|
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
21
21
|
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
22
|
-
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "node-summary": ("1",), "pairing-message": ("1",),
|
|
22
|
+
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",), "pairing-message": ("1",),
|
|
23
23
|
"pipeline": ("0.1",), "repair-request": ("1",), "repair-result": ("1",), "revocation-delta": ("1",), "session-handoff": ("1",), "service-load": ("1",), "share-registration": ("1",),
|
|
24
24
|
"share-registration-receipt": ("1",), "support-quality-daily": ("1",), "window-lease": ("1",),
|
|
25
25
|
}
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py
CHANGED
|
@@ -158,6 +158,41 @@ def grant_usage_context_findings(value: Any) -> List[Dict[str, str]]:
|
|
|
158
158
|
return findings
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def instance_share_grant(record: Any, machine_id: Any, agent_ref: Any, context: Any, at_ms: float) -> Dict[str, Any]:
|
|
162
|
+
"""Whether an organization's instance may run on ``machine_id`` now (FAA-125, N-11): the placement of ``agent_ref``
|
|
163
|
+
in ``context`` (as written; absent is personal) among that machine's packages, FAC-SEM-111 on it, and the grant's
|
|
164
|
+
window at ``at_ms`` (``startsAt`` <= at < ``expiresAt``; an unreadable instant or clock is outside it). Answers
|
|
165
|
+
``{"ok": True, "grant": {"id", "startsAt"?, "expiresAt"?}}`` (``{"ok": True}`` for a personal placement) or ``{"ok": False, "code", "detail"}`` with code
|
|
166
|
+
``placement-missing``, ``grant-refused`` or ``grant-window``; never raises. It reads the revision it is given, as
|
|
167
|
+
FAC-SEM-111 does: the caller verified it (``verify_estate_record``); a revocation delta is the hub's and the meter's."""
|
|
168
|
+
record = record if isinstance(record, dict) else {}
|
|
169
|
+
machine = next((m for m in _objects(record.get("machines")) if _jsv.same(m.get("id", _jsv.MISSING), machine_id)), {})
|
|
170
|
+
placement = next((p for p in _objects(machine.get("packages"))
|
|
171
|
+
if _jsv.same(p.get("agentRef", _jsv.MISSING), agent_ref)
|
|
172
|
+
and _jsv.same(p.get("context", _estate.PERSONAL_CONTEXT), context)), None)
|
|
173
|
+
if placement is None:
|
|
174
|
+
return {"ok": False, "code": "placement-missing", "detail": "this machine places no such agent in that context (FAC-SEM-074)"}
|
|
175
|
+
findings = _estate.placement_grant_findings(record, placement)
|
|
176
|
+
if findings:
|
|
177
|
+
return {"ok": False, "code": "grant-refused", "detail": "; ".join(f["message"] for f in findings)}
|
|
178
|
+
if not isinstance(placement.get("grant"), str):
|
|
179
|
+
return {"ok": True} # a personal placement, which names no grant
|
|
180
|
+
ident = _ids.normal_identifier(placement["grant"])
|
|
181
|
+
grant = next((g for g in _objects(record.get("shareGrants")) if _jsv.same(g.get("context", _jsv.MISSING), context)
|
|
182
|
+
and isinstance(g.get("meta"), dict) and isinstance(g["meta"].get("id"), str)
|
|
183
|
+
and _ids.normal_identifier(g["meta"]["id"]) == ident), None)
|
|
184
|
+
if grant is None: # defensive: FAC-SEM-111 has already matched one, so this answers rather than raises
|
|
185
|
+
return {"ok": False, "code": "grant-refused", "detail": "the record has no share grant for this placement"}
|
|
186
|
+
answer = {"id": grant["meta"]["id"]}
|
|
187
|
+
for name in ("startsAt", "expiresAt"):
|
|
188
|
+
if name in grant:
|
|
189
|
+
answer[name] = grant[name]
|
|
190
|
+
starts, expires = (_ftime.parse_rfc3339_ms(answer[n]) if n in answer else None for n in ("startsAt", "expiresAt"))
|
|
191
|
+
if (starts is not None and not starts <= at_ms) or (expires is not None and not at_ms < expires):
|
|
192
|
+
return {"ok": False, "code": "grant-window", "detail": "now is outside the share grant's window, or an instant cannot be read"}
|
|
193
|
+
return {"ok": True, "grant": answer}
|
|
194
|
+
|
|
195
|
+
|
|
161
196
|
def share_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
162
197
|
"""Kinds ``grant`` (FAC-SEM-110, 117), ``org-share-grants`` (117 with parent), ``share-registration`` (118) and
|
|
163
198
|
``device-attribution`` (045's grant.usage clause only); None for another kind."""
|