@passioncode-ai/fabric-agent-adapter 0.39.1 → 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.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -16,12 +16,13 @@ library only; the answer is JSON on stdout, exit 0 when it holds, 1 when it does
16
16
 
17
17
  import argparse
18
18
  import base64
19
+ import errno
19
20
  import hashlib
20
21
  import json
21
22
  import os
22
23
  import re
24
+ import stat as _stat
23
25
  import sys
24
- from datetime import datetime
25
26
  from pathlib import Path
26
27
  from typing import Any, Dict, List, Optional, Tuple
27
28
 
@@ -35,12 +36,20 @@ SIGNATURE_STATEMENT = "fabric-package-signature/1"
35
36
  # DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm.
36
37
  PACKAGE_ALGORITHMS = ("EdDSA",)
37
38
  SCHEMA_PATH = Path(__file__).resolve().parent / "fabric-package.schema.json"
38
- # What pack refuses to list: version-control and cache litter, and files that are credentials by their name.
39
- JUNK = re.compile(r"^(\.git|__pycache__|\.DS_Store|.*\.pyc)$")
40
- SECRET_FILE = re.compile(r"^(\.env(\..+)?|.*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc))$")
41
- SECRET_FILE_OK = re.compile(r"^\.env\.(example|sample|template)$")
42
- _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
39
+ # What pack refuses to list: version-control and cache litter, and files that are credentials by their name. Matched
40
+ # whole (fullmatch) and with JavaScript's `.`, so a name ending in a newline is judged as fabric-package.mjs judges it.
41
+ _JS_DOT = "[^\n\r\u2028\u2029]"
42
+ JUNK = re.compile(r"\.git|__pycache__|\.DS_Store|%s*\.pyc" % _JS_DOT)
43
+ SECRET_FILE = re.compile(r"\.env(\.%s+)?|%s*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc)" % (_JS_DOT, _JS_DOT))
44
+ SECRET_FILE_OK = re.compile(r"\.env\.(example|sample|template)")
45
+ _B64URL = re.compile(r"[A-Za-z0-9_-]+")
43
46
  _PKCS8_ED25519 = bytes.fromhex("302e020100300506032b657004220420")
47
+ #: How deep a document read from disk may nest. Python's recursion limit and Node's stack give out at different depths,
48
+ #: so both readers refuse past this one, well below either (FAA-78, finding 43); no package document comes near it.
49
+ MAX_NESTING = 256
50
+ #: JavaScript's whitespace, what String.prototype.trim and the regular expression class \s take (ECMA-262 WhiteSpace
51
+ #: and LineTerminator), so a key file and a URI are read as fabric-package.mjs reads them.
52
+ JS_WHITESPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
44
53
 
45
54
 
46
55
  class PackageError(Exception):
@@ -63,46 +72,30 @@ def sha256_of(data: Any) -> str:
63
72
 
64
73
  def package_digest(pkg: Dict[str, Any]) -> str:
65
74
  """SHA-256 over the UTF-8 RFC 8785 form of the package without ``digest`` and ``signatures`` (DEC-0050).
66
- Raises TypeError for a value with no RFC 8785 form."""
75
+ Raises TypeError for a value with no RFC 8785 form, a value nested past the stack included."""
67
76
  covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
68
- return sha256_of(fd.jcs(covered))
77
+ try:
78
+ return sha256_of(_jsv.jcs(covered))
79
+ except RecursionError: # nested past Python's stack: no form this kit can write, as Node's RangeError is none
80
+ raise TypeError("the package nests too deeply to have an RFC 8785 form") from None
69
81
 
70
82
 
71
83
  def package_signing_input(digest: str, role: str) -> bytes:
72
84
  """The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role."""
73
- return fd.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
74
-
85
+ return _jsv.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
75
86
 
76
- # --- Ed25519 signing (RFC 8032 §5.1.6) ------------------------------------------------------------------
77
-
78
- def _secret_scalar(seed: bytes) -> Tuple[int, bytes]:
79
- h = hashlib.sha512(seed).digest()
80
- a = int.from_bytes(h[:32], "little")
81
- a &= (1 << 254) - 8
82
- a |= 1 << 254
83
- return a, h[32:]
84
87
 
88
+ # --- Ed25519 signing: the core's (fabric_crypto, RFC 8032 §5.1.6), which never branches on the secret (finding 45) ---
85
89
 
86
90
  def ed25519_public_key(seed: bytes) -> bytes:
87
91
  """The 32-byte public key of a 32-byte Ed25519 seed."""
88
92
  if len(seed) != 32:
89
93
  raise PackageError("An Ed25519 private key is 32 bytes.")
90
- a, _ = _secret_scalar(seed)
91
- return fd._encode_point(fd._mul(a, fd._G))
92
-
94
+ return _crypto.ed25519_seed_public_key(seed)
93
95
 
94
- def ed25519_sign(seed: bytes, message: bytes) -> bytes:
95
- """A deterministic Ed25519 signature; the same seed and message give the bytes Node's crypto.sign gives."""
96
- a, prefix = _secret_scalar(seed)
97
- public = fd._encode_point(fd._mul(a, fd._G))
98
- r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % fd._L
99
- big_r = fd._encode_point(fd._mul(r, fd._G))
100
- k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % fd._L
101
- return big_r + ((r + k * a) % fd._L).to_bytes(32, "little")
102
96
 
103
-
104
- def _b64url(data: bytes) -> str:
105
- return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
97
+ from fabric_crypto import ed25519_sign # noqa: E402,F401 - the core's: the bytes Node's crypto.sign gives
98
+ from fabric_crypto import b64url as _b64url # noqa: E402 - the core's (FAA-65)
106
99
 
107
100
 
108
101
  def public_jwk(seed: bytes, kid: str) -> Dict[str, str]:
@@ -122,7 +115,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
122
115
  raise PackageError("Cannot read the key file %s: %s." % (path, exc.strerror)) from None
123
116
  if os.name == "posix" and stat.st_mode & 0o077:
124
117
  raise PackageError("The key file %s is readable by others (mode %o); chmod 600 it first." % (path, stat.st_mode & 0o777))
125
- text = raw.decode("utf-8", "replace").strip()
118
+ text = raw.decode("utf-8", "replace").strip(JS_WHITESPACE) # as JavaScript's trim: a leading BOM goes too
126
119
  if text.startswith("{"):
127
120
  try:
128
121
  jwk = json.loads(text)
@@ -132,7 +125,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
132
125
  raise PackageError("The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).")
133
126
  if jwk.get("alg", "EdDSA") != "EdDSA":
134
127
  raise PackageError("The key names alg %r; a package signature is EdDSA (DEC-0051)." % jwk.get("alg"))
135
- seed = fd._b64decode(jwk["d"]) if _B64URL.match(jwk["d"]) else b""
128
+ seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.fullmatch(jwk["d"]) else b""
136
129
  if len(seed) != 32:
137
130
  raise PackageError("The key's d is not a 32-byte Ed25519 private key.")
138
131
  if isinstance(jwk.get("x"), str) and jwk["x"] != _b64url(ed25519_public_key(seed)):
@@ -152,7 +145,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
152
145
  seed = der[16:]
153
146
  else:
154
147
  raise PackageError("The key file is neither a JWK nor a PEM private key.")
155
- if not isinstance(kid, str) or not re.match(r"^[A-Za-z0-9._:-]{1,128}$", kid):
148
+ if not isinstance(kid, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", kid):
156
149
  raise PackageError("Name the key with --kid (1-128 of A-Z a-z 0-9 . _ : -): the installing machine pins it by that name.")
157
150
  return seed, kid
158
151
 
@@ -168,16 +161,7 @@ def _schema() -> Dict[str, Any]:
168
161
  return _SCHEMA_CACHE["s"]
169
162
 
170
163
 
171
- def _json_equal(a: Any, b: Any) -> bool:
172
- if isinstance(a, bool) or isinstance(b, bool):
173
- return isinstance(a, bool) and isinstance(b, bool) and a == b
174
- if isinstance(a, (int, float)) and isinstance(b, (int, float)):
175
- return a == b
176
- if isinstance(a, list) and isinstance(b, list):
177
- return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
178
- if isinstance(a, dict) and isinstance(b, dict):
179
- return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a)
180
- return type(a) is type(b) and a == b
164
+ from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
181
165
 
182
166
 
183
167
  def _type_of(value: Any) -> str:
@@ -196,27 +180,67 @@ def _type_of(value: Any) -> str:
196
180
  return "object"
197
181
 
198
182
 
199
- _DATE_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
200
- _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s]*$")
183
+ # ASCII digits only and the whole string (fullmatch): Python's \d takes any Unicode digit and its $ a trailing newline,
184
+ # which JavaScript's do not (FAA-78, finding 43).
185
+ _DATE_TIME = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
186
+ _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^%s]*" % JS_WHITESPACE)
187
+ _MONTH_DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
188
+
189
+
190
+ def _leap(year: int) -> bool:
191
+ return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
201
192
 
202
193
 
203
194
  def _format_ok(fmt: str, value: Any) -> bool:
204
195
  if not isinstance(value, str):
205
196
  return True
206
197
  if fmt == "date-time":
207
- if not _DATE_TIME.match(value):
198
+ m = _DATE_TIME.fullmatch(value)
199
+ if not m:
208
200
  return False
209
- try:
210
- datetime.strptime(value[:19].replace("t", "T"), "%Y-%m-%dT%H:%M:%S")
211
- except ValueError:
201
+ year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
202
+ # The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29
203
+ # holds). datetime starts at year 1, so the days are counted here, as fabric-package.mjs counts them.
204
+ if not 1 <= month <= 12 or not 1 <= day <= (29 if month == 2 and _leap(year) else _MONTH_DAYS[month]):
205
+ return False
206
+ if hour > 23 or minute > 59 or second > 59:
212
207
  return False
213
- offset = value[19:].lstrip(".0123456789")
214
- return offset in ("Z", "z") or (int(offset[1:3]) < 24 and int(offset[4:6]) < 60)
208
+ return m.group(8) in ("Z", "z") or (int(m.group(9)) < 24 and int(m.group(10)) < 60)
215
209
  if fmt == "uri":
216
- return bool(_URI.match(value))
210
+ return bool(_URI.fullmatch(value))
217
211
  return True
218
212
 
219
213
 
214
+ _PATTERNS: Dict[str, Any] = {}
215
+
216
+
217
+ def _js_pattern(source: str) -> Any:
218
+ """A schema's ECMAScript pattern (``u`` flag) as Python reads it the same way: ``$`` is the end of the string, not
219
+ the end before a final newline; ``.`` stops at JavaScript's line terminators (\\n, \\r, U+2028, U+2029); ``\\d`` and
220
+ ``\\w`` are ASCII. Escapes and character classes are copied as written."""
221
+ if source not in _PATTERNS:
222
+ out: List[str] = []
223
+ i, in_class = 0, False
224
+ while i < len(source):
225
+ ch = source[i]
226
+ if ch == "\\":
227
+ out.append(source[i:i + 2])
228
+ i += 2
229
+ continue
230
+ if in_class:
231
+ in_class = ch != "]"
232
+ elif ch == "[":
233
+ in_class = True
234
+ elif ch == "$":
235
+ ch = r"\Z"
236
+ elif ch == ".":
237
+ ch = _JS_DOT
238
+ out.append(ch)
239
+ i += 1
240
+ _PATTERNS[source] = re.compile("".join(out), re.ASCII)
241
+ return _PATTERNS[source]
242
+
243
+
220
244
  def _pointer(token: Any) -> str:
221
245
  return str(token).replace("~", "~0").replace("/", "~1")
222
246
 
@@ -249,7 +273,7 @@ def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> No
249
273
  out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
250
274
  if "maxLength" in schema and length > schema["maxLength"]:
251
275
  out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
252
- if "pattern" in schema and not re.search(schema["pattern"], value):
276
+ if "pattern" in schema and not _js_pattern(schema["pattern"]).search(value):
253
277
  out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
254
278
  if "format" in schema and not _format_ok(schema["format"], value):
255
279
  out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
@@ -373,7 +397,7 @@ def _whole(value: Dict[str, Any]) -> List[Dict[str, str]]:
373
397
  return out
374
398
 
375
399
 
376
- _UNDEFINED = object()
400
+ from fabric_jsvalue import MISSING as _UNDEFINED # noqa: E402 - the core's absent value (FAA-65)
377
401
 
378
402
 
379
403
  def _same_member(obj: Dict[str, Any], name: str, other: Any) -> bool:
@@ -386,21 +410,9 @@ def _same_member(obj: Dict[str, Any], name: str, other: Any) -> bool:
386
410
  return _json_equal(mine, other)
387
411
 
388
412
 
389
- def _js_string(value: Any) -> str:
390
- """JavaScript's ``String(value)`` for a JSON value, as the contract's messages print it."""
391
- if value is _UNDEFINED:
392
- return "undefined"
393
- if value is None:
394
- return "null"
395
- if isinstance(value, bool):
396
- return "true" if value else "false"
397
- if isinstance(value, (int, float)):
398
- return fd.jcs(value)
399
- if isinstance(value, list):
400
- return ",".join("" if item is None else _js_string(item) for item in value)
401
- if isinstance(value, dict):
402
- return "[object Object]"
403
- return value
413
+ from fabric_jsvalue import js_string as _js_string # noqa: E402 - the core's (FAA-65)
414
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
415
+ import fabric_crypto as _crypto # noqa: E402 - the core (FAA-65)
404
416
 
405
417
 
406
418
  def _no_secret(value: Dict[str, Any]) -> List[Dict[str, str]]:
@@ -481,6 +493,63 @@ def _mcp_roles(value: Dict[str, Any]) -> List[Dict[str, str]]:
481
493
  return out
482
494
 
483
495
 
496
+ # --- the path rule (FAA-78, finding 40; decision D5) ----------------------------------------------------------------
497
+
498
+ def _control(ch: str) -> bool:
499
+ """C0 and C1 controls, DEL, and the two Unicode line terminators JavaScript's ``.`` stops at."""
500
+ code = ord(ch)
501
+ return code < 0x20 or 0x7F <= code <= 0x9F or code in (0x2028, 0x2029)
502
+
503
+
504
+ def path_problem(path: Any) -> Optional[str]:
505
+ """Why ``path`` is no packaged path, or None. The schema's pattern says the same in one expression, but JavaScript's
506
+ ``.`` stops at U+2028 and U+2029, so in Node and in the contract's own ajv its look-aheads never see a ``..``, a
507
+ backslash or a ``//`` after one (``a\u2028/../../etc/passwd`` passes there). This rule reads the path character by
508
+ character in both kits, and refuses C1 controls, DEL and the line terminators besides. A non-string is the
509
+ schema's to refuse."""
510
+ if not isinstance(path, str):
511
+ return None
512
+ if not 1 <= len(path) <= 256:
513
+ return "a path is 1 to 256 characters"
514
+ if any(_control(ch) for ch in path):
515
+ return "a path carries no control character and no line or paragraph separator"
516
+ if path[0] in "/~":
517
+ return "a path is relative to the package folder: it never starts with / or ~"
518
+ if "\\" in path or ":" in path:
519
+ return "a path is written with forward slashes and no colon"
520
+ if "//" in path or path.endswith("/"):
521
+ return "a path names a file: no empty segment and no trailing slash"
522
+ if any(segment in (".", "..") for segment in path.split("/")):
523
+ return "a path never steps through . or .."
524
+ return None
525
+
526
+
527
+ def _path_fields(pkg: Dict[str, Any]) -> List[Tuple[str, Any]]:
528
+ """Every member the schema types as a path, by its JSON pointer."""
529
+ fields: List[Tuple[str, Any]] = [("/provider", pkg.get("provider", _UNDEFINED))]
530
+ evals = pkg.get("evals")
531
+ if isinstance(evals, dict):
532
+ fields.append(("/evals/suite", evals.get("suite", _UNDEFINED)))
533
+ for name in ("state", "dependencies", "files"):
534
+ items = pkg.get(name)
535
+ for index, entry in enumerate(items if isinstance(items, list) else []):
536
+ if isinstance(entry, dict):
537
+ fields.append(("/%s/%d/path" % (name, index), entry.get("path", _UNDEFINED)))
538
+ return [(at, value) for at, value in fields if value is not _UNDEFINED]
539
+
540
+
541
+ def path_problems(pkg: Any) -> List[Dict[str, str]]:
542
+ """The path rule over every path the package names: ``{instancePath, message}`` per path refused."""
543
+ if not isinstance(pkg, dict):
544
+ return []
545
+ out = []
546
+ for at, value in _path_fields(pkg):
547
+ why = path_problem(value)
548
+ if why is not None:
549
+ out.append({"instancePath": at, "message": why})
550
+ return out
551
+
552
+
484
553
  def package_findings(pkg: Dict[str, Any]) -> List[Dict[str, str]]:
485
554
  """FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order."""
486
555
  return _whole(pkg) + _no_secret(pkg) + _agent_ref(pkg) + _mcp_roles(pkg)
@@ -492,8 +561,8 @@ def _parsed(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
492
561
  if data is None:
493
562
  return None
494
563
  try:
495
- value = json.loads(data.decode("utf-8", "replace"))
496
- except ValueError:
564
+ value = json.loads(data.decode("utf-8"))
565
+ except (ValueError, RecursionError): # invalid UTF-8 is no manifest, in both kits
497
566
  return None
498
567
  return value if isinstance(value, dict) else None
499
568
 
@@ -537,13 +606,13 @@ def _key_for(keys: Any, kid: str, alg: str) -> Dict[str, Any]:
537
606
  if jwk.get("alg", _UNDEFINED) != alg:
538
607
  return {"problem": "key-algorithm-mismatch"}
539
608
  x = jwk.get("x")
540
- if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.match(x)
609
+ if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.fullmatch(x)
541
610
  or "d" in jwk or ("use" in jwk and jwk["use"] != "sig")):
542
611
  return {"problem": "unusable-key"}
543
- raw = fd._b64decode(x)
612
+ raw = _crypto.b64url_decode(x)
544
613
  if len(raw) != 32:
545
614
  return {"problem": "unusable-key"}
546
- if fd.is_small_order_key(raw): # DEC-0045: refused before any verification
615
+ if _crypto.is_small_order_key(raw): # DEC-0045: refused before any verification
547
616
  return {"problem": "small-order-key"}
548
617
  return {"key": raw}
549
618
 
@@ -570,7 +639,7 @@ def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False)
570
639
  trust = trust if isinstance(trust, dict) else {}
571
640
  for index, entry in enumerate(signatures):
572
641
  if (not isinstance(entry, dict) or entry.get("role") not in ("publisher", "organization") or not isinstance(entry.get("alg"), str)
573
- or not isinstance(entry.get("kid"), str) or not isinstance(entry.get("sig"), str) or not _B64URL.match(entry["sig"])):
642
+ or not isinstance(entry.get("kid"), str) or not isinstance(entry.get("sig"), str) or not _B64URL.fullmatch(entry["sig"])):
574
643
  return {"ok": False, "problem": "malformed", "signature": index}
575
644
  if entry["alg"] not in PACKAGE_ALGORITHMS:
576
645
  return {"ok": False, "problem": "unknown-algorithm", "signature": index}
@@ -580,8 +649,8 @@ def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False)
580
649
  continue
581
650
  if "problem" in found:
582
651
  return {"ok": False, "problem": found["problem"], "signature": index}
583
- signature = fd._b64decode(entry["sig"])
584
- if len(signature) != 64 or not fd.ed25519_verify(found["key"], package_signing_input(digest, entry["role"]), signature):
652
+ signature = _crypto.b64url_decode(entry["sig"])
653
+ if len(signature) != 64 or not _crypto.ed25519_verify(found["key"], package_signing_input(digest, entry["role"]), signature):
585
654
  return {"ok": False, "problem": "bad-signature", "signature": index}
586
655
  signers.append({"role": entry["role"], "kid": entry["kid"]})
587
656
  if countersigned is True and not any(s["role"] == "organization" for s in signers):
@@ -614,50 +683,142 @@ def consumer_placement(pkg: Any, configured: Any) -> List[Dict[str, str]]:
614
683
  # --- the folder on disk -----------------------------------------------------------------------------------
615
684
 
616
685
  def _litter(name: str) -> Optional[str]:
617
- if JUNK.match(name):
686
+ if JUNK.fullmatch(name):
618
687
  return "litter"
619
- if SECRET_FILE.match(name) and not SECRET_FILE_OK.match(name):
688
+ if SECRET_FILE.fullmatch(name) and not SECRET_FILE_OK.fullmatch(name):
620
689
  return "a credential file"
621
690
  return None
622
691
 
623
692
 
693
+ #: Opening a packaged file: never through a link, never waiting on a pipe (FAA-78, finding 42). Folders are opened
694
+ #: without following a link too, and what is in one is opened relative to it, so a folder swapped for a link mid-walk is
695
+ #: never followed either; where the platform has no such calls (Windows), the walk goes by path.
696
+ _NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
697
+ _FILE_FLAGS = os.O_RDONLY | _NOFOLLOW | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
698
+ _DIR_FLAGS = os.O_RDONLY | _NOFOLLOW | getattr(os, "O_DIRECTORY", 0)
699
+ _LINK_ERRNOS = (errno.ELOOP, errno.EMLINK) # open() on a link it may not follow: ELOOP (Linux, macOS), EMLINK (FreeBSD)
700
+ _BY_FOLDER = bool(_NOFOLLOW) and hasattr(os, "O_DIRECTORY") and os.open in os.supports_dir_fd and os.scandir in getattr(os, "supports_fd", set())
701
+
702
+
703
+ def _unreadable(rel: str, exc: OSError) -> PackageError:
704
+ """``Cannot read <path>: EACCES.``: the error's symbolic name, as Node's error.code names it."""
705
+ return PackageError("Cannot read %s: %s." % (rel or ".", errno.errorcode.get(exc.errno or -1) or exc.strerror or exc))
706
+
707
+
708
+ def _read_file(name: str, dir_fd: Optional[int]) -> Tuple[Optional[bytes], Optional[str]]:
709
+ """``(bytes, None)`` for a regular file, ``(None, why)`` for a link or anything else. Raises OSError."""
710
+ try:
711
+ handle = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
712
+ except OSError as exc:
713
+ if _NOFOLLOW and exc.errno in _LINK_ERRNOS:
714
+ return None, "a symlink"
715
+ raise
716
+ try:
717
+ if not _stat.S_ISREG(os.fstat(handle).st_mode):
718
+ return None, "not a regular file"
719
+ chunks = []
720
+ while True:
721
+ chunk = os.read(handle, 1 << 20)
722
+ if not chunk:
723
+ return b"".join(chunks), None
724
+ chunks.append(chunk)
725
+ finally:
726
+ os.close(handle)
727
+
728
+
729
+ def _walk(here: Any, rel: str, files: Dict[str, bytes], refused: List[str]) -> None:
730
+ """One folder: ``here`` is its descriptor when the walk goes by folder, else its path."""
731
+ by_fd = isinstance(here, int)
732
+ try:
733
+ with os.scandir(here) as listing:
734
+ entries = sorted(listing, key=lambda e: e.name)
735
+ except OSError as exc:
736
+ raise _unreadable(rel, exc) from None
737
+ for entry in entries:
738
+ where = "%s/%s" % (rel, entry.name) if rel else entry.name
739
+ try:
740
+ why = "a symlink" if entry.is_symlink() else _litter(entry.name)
741
+ folder = entry.is_dir(follow_symlinks=False)
742
+ if why is None and not folder and not entry.is_file(follow_symlinks=False):
743
+ why = "not a regular file"
744
+ if why is None and folder:
745
+ inner = os.open(entry.name, _DIR_FLAGS, dir_fd=here) if by_fd else entry.path
746
+ try:
747
+ _walk(inner, where, files, refused)
748
+ finally:
749
+ if by_fd:
750
+ os.close(inner)
751
+ continue
752
+ if why is None:
753
+ data, why = _read_file(entry.name if by_fd else entry.path, here if by_fd else None)
754
+ if why is None:
755
+ files[where] = data # type: ignore[assignment]
756
+ continue
757
+ except OSError as exc:
758
+ if not (_NOFOLLOW and exc.errno in _LINK_ERRNOS):
759
+ raise _unreadable(where, exc) from None
760
+ why = "a symlink"
761
+ refused.append("%s (%s)" % (where, why))
762
+
763
+
624
764
  def read_folder(folder: Any) -> Dict[str, bytes]:
625
765
  """Every regular file under ``folder`` by its forward-slash path. Refuses a symlink, a special file, version-control
626
- or cache litter, and a file that is a credential by its name — each named, so the person can remove it or pack a
627
- clean copy."""
766
+ or cache litter, and a file that is a credential by its name, each named, so the person can remove it or pack a
767
+ clean copy. A file or folder it cannot read is a refusal naming it (``Cannot read <path>: EACCES.``), never
768
+ skipped (FAA-78, finding 41); a file is opened without following a link and read only when it is a regular file
769
+ (finding 42)."""
628
770
  root = Path(folder)
629
771
  if not root.is_dir():
630
772
  raise PackageError("%s is not a folder." % root)
631
773
  files: Dict[str, bytes] = {}
632
774
  refused: List[str] = []
633
- for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
634
- here = Path(dirpath)
635
- rel_dir = here.relative_to(root).as_posix()
636
- for name in sorted(dirnames):
637
- rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
638
- why = "a symlink" if (here / name).is_symlink() else _litter(name)
639
- if why:
640
- refused.append("%s (%s)" % (rel, why))
641
- dirnames.remove(name)
642
- for name in filenames:
643
- rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
644
- full = here / name
645
- why = "a symlink" if full.is_symlink() else (None if full.is_file() else "not a regular file") or _litter(name)
646
- if why:
647
- refused.append("%s (%s)" % (rel, why))
648
- continue
649
- files[rel] = full.read_bytes()
775
+ try:
776
+ top = os.open(str(root), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) if _BY_FOLDER else str(root)
777
+ except OSError as exc:
778
+ raise _unreadable(str(root), exc) from None
779
+ try:
780
+ _walk(top, "", files, refused)
781
+ finally:
782
+ if _BY_FOLDER:
783
+ os.close(top) # type: ignore[arg-type]
650
784
  if refused:
651
785
  raise PackageError("Not packed: remove these or pack a clean copy: " + "; ".join(sorted(refused)))
652
786
  return files
653
787
 
654
788
 
789
+ def _nesting(text: str) -> int:
790
+ """How deep the arrays and objects of a JSON text nest, read without recursion (strings skipped)."""
791
+ depth = deepest = 0
792
+ i, end = 0, len(text)
793
+ while i < end:
794
+ ch = text[i]
795
+ if ch == '"':
796
+ i += 1
797
+ while i < end and text[i] != '"':
798
+ i += 2 if text[i] == "\\" else 1
799
+ elif ch in "[{":
800
+ depth += 1
801
+ deepest = max(deepest, depth)
802
+ elif ch in "]}":
803
+ depth -= 1
804
+ i += 1
805
+ return deepest
806
+
807
+
655
808
  def _load_json(data: bytes, what: str) -> Dict[str, Any]:
809
+ """A JSON object from strict UTF-8 bytes: no member named twice, nesting at most MAX_NESTING deep. Every refusal
810
+ is a PackageError, never a traceback (a RecursionError included)."""
811
+ try:
812
+ text = data.decode("utf-8")
813
+ except UnicodeDecodeError as exc:
814
+ raise PackageError("%s is not JSON: %s" % (what, exc)) from None
815
+ if _nesting(text) > MAX_NESTING:
816
+ raise PackageError("%s nests deeper than %d levels." % (what, MAX_NESTING))
656
817
  try:
657
- value = json.loads(data.decode("utf-8"), object_pairs_hook=fd._no_duplicates)
818
+ value = json.loads(text, object_pairs_hook=fd._no_duplicates)
658
819
  except fd._Duplicate as exc:
659
820
  raise PackageError("%s names %s twice in one object." % (what, json.dumps(exc.args[0], ensure_ascii=False))) from None
660
- except (ValueError, UnicodeDecodeError) as exc:
821
+ except (ValueError, RecursionError) as exc:
661
822
  raise PackageError("%s is not JSON: %s" % (what, exc)) from None
662
823
  if not isinstance(value, dict):
663
824
  raise PackageError("%s is not a JSON object." % what)
@@ -670,12 +831,12 @@ def dumps(value: Any, indent: str = "") -> str:
670
831
  if isinstance(value, dict):
671
832
  if not value:
672
833
  return "{}"
673
- return "{\n" + ",\n".join(inner + fd._string(k) + ": " + dumps(v, inner) for k, v in value.items()) + "\n" + indent + "}"
834
+ return "{\n" + ",\n".join(inner + _jsv.json_string_literal(k) + ": " + dumps(v, inner) for k, v in value.items()) + "\n" + indent + "}"
674
835
  if isinstance(value, list):
675
836
  if not value:
676
837
  return "[]"
677
838
  return "[\n" + ",\n".join(inner + dumps(v, inner) for v in value) + "\n" + indent + "]"
678
- return fd.jcs(value)
839
+ return _jsv.jcs(value)
679
840
 
680
841
 
681
842
  def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, Any], List[str]]:
@@ -699,12 +860,16 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
699
860
  notes.append("dropped %d signature(s): a repacked package is signed again" % len(draft["signatures"]))
700
861
  pkg["files"] = [{"path": p, "sha256": sha256_of(files[p])} for p in sorted((p for p in files if p != PACKAGE_FILE),
701
862
  key=lambda p: p.encode("utf-16-be", "surrogatepass"))]
702
- pkg["digest"] = package_digest(pkg)
863
+ try:
864
+ pkg["digest"] = package_digest(pkg)
865
+ except TypeError as exc:
866
+ raise PackageError("%s has no RFC 8785 form, so it has no digest: %s" % (PACKAGE_FILE, exc)) from None
703
867
  return pkg, notes
704
868
 
705
869
 
706
870
  def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
707
871
  problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
872
+ problems += [dict(p, rule="path") for p in path_problems(pkg)]
708
873
  problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
709
874
  problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": p["problem"]} for p in check_package_folder(pkg, files)]
710
875
  return problems
@@ -735,9 +900,9 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
735
900
 
736
901
 
737
902
  def fd_write(path: Path, text: str) -> None:
738
- """Write through a temporary file in the same folder and rename it over, so a reader never sees half a file."""
903
+ """Write through a temporary file and rename it over; bytes, as the Node kit writes them (text mode: \r\n on Windows)."""
739
904
  temp = path.with_name(".%s.tmp-%d" % (path.name, os.getpid()))
740
- temp.write_text(text, encoding="utf-8")
905
+ temp.write_bytes(text.encode("utf-8"))
741
906
  os.replace(temp, path)
742
907
 
743
908
 
@@ -783,6 +948,17 @@ def verify(folder: Any, trust: Dict[str, Any], countersigned: bool = False) -> D
783
948
  return {"ok": bool(verdict.get("ok")) and not problems, "verdict": verdict, "problems": problems}
784
949
 
785
950
 
951
+ def _emit(text: str) -> None:
952
+ """One answer line, UTF-8 and \\n as the Node kit writes it, whatever the locale (a Windows pipe is cp1252; FAA-72)."""
953
+ buffer = getattr(sys.stdout, "buffer", None)
954
+ if buffer is None: # a stream a caller put in place takes text
955
+ sys.stdout.write(text + "\n")
956
+ return
957
+ sys.stdout.flush()
958
+ buffer.write((text + "\n").encode("utf-8"))
959
+ buffer.flush()
960
+
961
+
786
962
  def main(argv: Optional[List[str]] = None) -> int:
787
963
  parser = argparse.ArgumentParser(prog="fabric_package.py", description="Pack, sign and verify a fabric-package/1 folder.")
788
964
  sub = parser.add_subparsers(dest="command", required=True)
@@ -814,9 +990,9 @@ def main(argv: Optional[List[str]] = None) -> int:
814
990
  trust = _load_json(Path(args.trust).read_bytes(), args.trust)
815
991
  answer = verify(args.folder, trust, countersigned=args.countersigned)
816
992
  except (PackageError, OSError) as exc:
817
- print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, separators=(",", ":")))
993
+ _emit(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, separators=(",", ":")))
818
994
  return 1
819
- print(dumps(answer))
995
+ _emit(dumps(answer))
820
996
  return 0 if answer.get("ok") else 1
821
997
 
822
998