@passioncode-ai/passioncode 0.2.38 → 0.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  56. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
  57. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  60. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  61. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
  62. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  63. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  64. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  65. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
  66. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  67. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
  68. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  69. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  70. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
  71. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
  72. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  73. 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")
@@ -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)."""
@@ -15,11 +15,11 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
15
15
  PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
16
16
  "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "agent-directory": ("1",), "call-record": ("1",), "coding-agents": ("1",), "credential-decision": ("1",), "credential-request": ("1",), "credential-revocation": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
17
17
  "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
18
- "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-credential-envelope": ("1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
18
+ "fabric-actions": ("0.1",), "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-credential-envelope": ("1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
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
  }
@@ -181,6 +181,15 @@ from fabric_backups import ( # noqa: F401
181
181
  newest_intact_backup,
182
182
  restore_plan,
183
183
  )
184
+ from fabric_actions import ( # noqa: F401
185
+ ACTIONS_EXTENSION_KEY,
186
+ action_decision,
187
+ action_rules,
188
+ actions_extension,
189
+ actions_extension_rules,
190
+ actions_view,
191
+ decision_shape,
192
+ )
184
193
  from fabric_connections import ( # noqa: F401
185
194
  CONNECTIONS_EXTENSION_KEY,
186
195
  connections_extension,