@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +9 -3
  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 +28 -30
  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 +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. 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,9 +72,12 @@ 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(_jsv.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:
@@ -73,34 +85,16 @@ def package_signing_input(digest: str, role: str) -> bytes:
73
85
  return _jsv.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
74
86
 
75
87
 
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
-
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 _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_G))
92
-
93
-
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 = _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_G))
98
- r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _crypto._ED_L
99
- big_r = _crypto._ed_encode_point(_crypto._ed_mul(r, _crypto._ED_G))
100
- k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _crypto._ED_L
101
- return big_r + ((r + k * a) % _crypto._ED_L).to_bytes(32, "little")
94
+ return _crypto.ed25519_seed_public_key(seed)
102
95
 
103
96
 
97
+ from fabric_crypto import ed25519_sign # noqa: E402,F401 - the core's: the bytes Node's crypto.sign gives
104
98
  from fabric_crypto import b64url as _b64url # noqa: E402 - the core's (FAA-65)
105
99
 
106
100
 
@@ -121,7 +115,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
121
115
  raise PackageError("Cannot read the key file %s: %s." % (path, exc.strerror)) from None
122
116
  if os.name == "posix" and stat.st_mode & 0o077:
123
117
  raise PackageError("The key file %s is readable by others (mode %o); chmod 600 it first." % (path, stat.st_mode & 0o777))
124
- 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
125
119
  if text.startswith("{"):
126
120
  try:
127
121
  jwk = json.loads(text)
@@ -131,7 +125,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
131
125
  raise PackageError("The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).")
132
126
  if jwk.get("alg", "EdDSA") != "EdDSA":
133
127
  raise PackageError("The key names alg %r; a package signature is EdDSA (DEC-0051)." % jwk.get("alg"))
134
- seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.match(jwk["d"]) else b""
128
+ seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.fullmatch(jwk["d"]) else b""
135
129
  if len(seed) != 32:
136
130
  raise PackageError("The key's d is not a 32-byte Ed25519 private key.")
137
131
  if isinstance(jwk.get("x"), str) and jwk["x"] != _b64url(ed25519_public_key(seed)):
@@ -151,7 +145,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
151
145
  seed = der[16:]
152
146
  else:
153
147
  raise PackageError("The key file is neither a JWK nor a PEM private key.")
154
- 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):
155
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.")
156
150
  return seed, kid
157
151
 
@@ -186,27 +180,67 @@ def _type_of(value: Any) -> str:
186
180
  return "object"
187
181
 
188
182
 
189
- _DATE_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
190
- _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)
191
192
 
192
193
 
193
194
  def _format_ok(fmt: str, value: Any) -> bool:
194
195
  if not isinstance(value, str):
195
196
  return True
196
197
  if fmt == "date-time":
197
- if not _DATE_TIME.match(value):
198
+ m = _DATE_TIME.fullmatch(value)
199
+ if not m:
198
200
  return False
199
- try:
200
- datetime.strptime(value[:19].replace("t", "T"), "%Y-%m-%dT%H:%M:%S")
201
- 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]):
202
205
  return False
203
- offset = value[19:].lstrip(".0123456789")
204
- return offset in ("Z", "z") or (int(offset[1:3]) < 24 and int(offset[4:6]) < 60)
206
+ if hour > 23 or minute > 59 or second > 59:
207
+ return False
208
+ return m.group(8) in ("Z", "z") or (int(m.group(9)) < 24 and int(m.group(10)) < 60)
205
209
  if fmt == "uri":
206
- return bool(_URI.match(value))
210
+ return bool(_URI.fullmatch(value))
207
211
  return True
208
212
 
209
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
+
210
244
  def _pointer(token: Any) -> str:
211
245
  return str(token).replace("~", "~0").replace("/", "~1")
212
246
 
@@ -239,7 +273,7 @@ def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> No
239
273
  out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
240
274
  if "maxLength" in schema and length > schema["maxLength"]:
241
275
  out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
242
- if "pattern" in schema and not re.search(schema["pattern"], value):
276
+ if "pattern" in schema and not _js_pattern(schema["pattern"]).search(value):
243
277
  out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
244
278
  if "format" in schema and not _format_ok(schema["format"], value):
245
279
  out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
@@ -459,6 +493,63 @@ def _mcp_roles(value: Dict[str, Any]) -> List[Dict[str, str]]:
459
493
  return out
460
494
 
461
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
+
462
553
  def package_findings(pkg: Dict[str, Any]) -> List[Dict[str, str]]:
463
554
  """FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order."""
464
555
  return _whole(pkg) + _no_secret(pkg) + _agent_ref(pkg) + _mcp_roles(pkg)
@@ -470,8 +561,8 @@ def _parsed(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
470
561
  if data is None:
471
562
  return None
472
563
  try:
473
- value = json.loads(data.decode("utf-8", "replace"))
474
- except ValueError:
564
+ value = json.loads(data.decode("utf-8"))
565
+ except (ValueError, RecursionError): # invalid UTF-8 is no manifest, in both kits
475
566
  return None
476
567
  return value if isinstance(value, dict) else None
477
568
 
@@ -515,7 +606,7 @@ def _key_for(keys: Any, kid: str, alg: str) -> Dict[str, Any]:
515
606
  if jwk.get("alg", _UNDEFINED) != alg:
516
607
  return {"problem": "key-algorithm-mismatch"}
517
608
  x = jwk.get("x")
518
- 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)
519
610
  or "d" in jwk or ("use" in jwk and jwk["use"] != "sig")):
520
611
  return {"problem": "unusable-key"}
521
612
  raw = _crypto.b64url_decode(x)
@@ -548,7 +639,7 @@ def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False)
548
639
  trust = trust if isinstance(trust, dict) else {}
549
640
  for index, entry in enumerate(signatures):
550
641
  if (not isinstance(entry, dict) or entry.get("role") not in ("publisher", "organization") or not isinstance(entry.get("alg"), str)
551
- 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"])):
552
643
  return {"ok": False, "problem": "malformed", "signature": index}
553
644
  if entry["alg"] not in PACKAGE_ALGORITHMS:
554
645
  return {"ok": False, "problem": "unknown-algorithm", "signature": index}
@@ -592,50 +683,142 @@ def consumer_placement(pkg: Any, configured: Any) -> List[Dict[str, str]]:
592
683
  # --- the folder on disk -----------------------------------------------------------------------------------
593
684
 
594
685
  def _litter(name: str) -> Optional[str]:
595
- if JUNK.match(name):
686
+ if JUNK.fullmatch(name):
596
687
  return "litter"
597
- 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):
598
689
  return "a credential file"
599
690
  return None
600
691
 
601
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
+
602
764
  def read_folder(folder: Any) -> Dict[str, bytes]:
603
765
  """Every regular file under ``folder`` by its forward-slash path. Refuses a symlink, a special file, version-control
604
- or cache litter, and a file that is a credential by its name — each named, so the person can remove it or pack a
605
- 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)."""
606
770
  root = Path(folder)
607
771
  if not root.is_dir():
608
772
  raise PackageError("%s is not a folder." % root)
609
773
  files: Dict[str, bytes] = {}
610
774
  refused: List[str] = []
611
- for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
612
- here = Path(dirpath)
613
- rel_dir = here.relative_to(root).as_posix()
614
- for name in sorted(dirnames):
615
- rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
616
- why = "a symlink" if (here / name).is_symlink() else _litter(name)
617
- if why:
618
- refused.append("%s (%s)" % (rel, why))
619
- dirnames.remove(name)
620
- for name in filenames:
621
- rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
622
- full = here / name
623
- why = "a symlink" if full.is_symlink() else (None if full.is_file() else "not a regular file") or _litter(name)
624
- if why:
625
- refused.append("%s (%s)" % (rel, why))
626
- continue
627
- 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]
628
784
  if refused:
629
785
  raise PackageError("Not packed: remove these or pack a clean copy: " + "; ".join(sorted(refused)))
630
786
  return files
631
787
 
632
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
+
633
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)."""
634
811
  try:
635
- value = json.loads(data.decode("utf-8"), object_pairs_hook=fd._no_duplicates)
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))
817
+ try:
818
+ value = json.loads(text, object_pairs_hook=fd._no_duplicates)
636
819
  except fd._Duplicate as exc:
637
820
  raise PackageError("%s names %s twice in one object." % (what, json.dumps(exc.args[0], ensure_ascii=False))) from None
638
- except (ValueError, UnicodeDecodeError) as exc:
821
+ except (ValueError, RecursionError) as exc:
639
822
  raise PackageError("%s is not JSON: %s" % (what, exc)) from None
640
823
  if not isinstance(value, dict):
641
824
  raise PackageError("%s is not a JSON object." % what)
@@ -677,12 +860,16 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
677
860
  notes.append("dropped %d signature(s): a repacked package is signed again" % len(draft["signatures"]))
678
861
  pkg["files"] = [{"path": p, "sha256": sha256_of(files[p])} for p in sorted((p for p in files if p != PACKAGE_FILE),
679
862
  key=lambda p: p.encode("utf-16-be", "surrogatepass"))]
680
- 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
681
867
  return pkg, notes
682
868
 
683
869
 
684
870
  def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
685
871
  problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
872
+ problems += [dict(p, rule="path") for p in path_problems(pkg)]
686
873
  problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
687
874
  problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": p["problem"]} for p in check_package_folder(pkg, files)]
688
875
  return problems
@@ -713,9 +900,9 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
713
900
 
714
901
 
715
902
  def fd_write(path: Path, text: str) -> None:
716
- """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)."""
717
904
  temp = path.with_name(".%s.tmp-%d" % (path.name, os.getpid()))
718
- temp.write_text(text, encoding="utf-8")
905
+ temp.write_bytes(text.encode("utf-8"))
719
906
  os.replace(temp, path)
720
907
 
721
908
 
@@ -761,6 +948,17 @@ def verify(folder: Any, trust: Dict[str, Any], countersigned: bool = False) -> D
761
948
  return {"ok": bool(verdict.get("ok")) and not problems, "verdict": verdict, "problems": problems}
762
949
 
763
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
+
764
962
  def main(argv: Optional[List[str]] = None) -> int:
765
963
  parser = argparse.ArgumentParser(prog="fabric_package.py", description="Pack, sign and verify a fabric-package/1 folder.")
766
964
  sub = parser.add_subparsers(dest="command", required=True)
@@ -792,9 +990,9 @@ def main(argv: Optional[List[str]] = None) -> int:
792
990
  trust = _load_json(Path(args.trust).read_bytes(), args.trust)
793
991
  answer = verify(args.folder, trust, countersigned=args.countersigned)
794
992
  except (PackageError, OSError) as exc:
795
- 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=(",", ":")))
796
994
  return 1
797
- print(dumps(answer))
995
+ _emit(dumps(answer))
798
996
  return 0 if answer.get("ok") else 1
799
997
 
800
998
 
@@ -36,7 +36,7 @@ MAX_PROBE_MS = 5000
36
36
  #: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
37
37
  OPEN_VENDOR = "open-vendor"
38
38
  USER_CONTENT = "user-content"
39
- _BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
39
+ _BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z")
40
40
 
41
41
 
42
42
  class ProfileError(ValueError):
@@ -65,7 +65,7 @@ def secret_value(value: str) -> bool:
65
65
  def _path_like(piece: str) -> bool:
66
66
  return bool(_PATH_LIKE.match(piece)) or (
67
67
  bool(re.search(r"[\\/]", piece))
68
- and (bool(re.search(r"\.[A-Za-z0-9]{1,8}$", piece)) or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", s) for s in re.split(r"[\\/]", piece))))
68
+ and (bool(re.search(r"\.[A-Za-z0-9]{1,8}\Z", piece)) or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", s) for s in re.split(r"[\\/]", piece))))
69
69
 
70
70
 
71
71
  def secret_argument(value: str) -> bool:
@@ -12,6 +12,7 @@ import subprocess
12
12
  import sys
13
13
  import tempfile
14
14
  import time
15
+ import urllib.error
15
16
  import urllib.request
16
17
  from pathlib import Path, PurePath
17
18
  from typing import Any, Callable, Dict, List, Optional, Sequence
@@ -40,6 +41,9 @@ from fabric_lock import (
40
41
  DEFAULT_EXIT_TIMEOUT = 15
41
42
 
42
43
 
44
+ _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
45
+
46
+
43
47
  def _no_marker_argument(argv: Sequence[str]) -> None:
44
48
  """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
45
49
  if _marker_arguments(list(argv)):
@@ -64,10 +68,12 @@ def _plain(value: str, what: str) -> str:
64
68
  return value
65
69
 
66
70
 
67
- def _systemd_quote(value: str) -> str:
71
+ def _systemd_quote(value: str, dollar: bool = True) -> str:
72
+ """A quoted systemd value. ExecStart= expands $VAR, so its $ is doubled; Environment= expands none, so `dollar`
73
+ False leaves it alone (finding 17). Both expand % specifiers."""
68
74
  _plain(value, "an argument")
69
- escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
70
- return '"%s"' % escaped
75
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
76
+ return '"%s"' % (escaped.replace("$", "$$") if dollar else escaped)
71
77
 
72
78
 
73
79
  def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
@@ -96,9 +102,9 @@ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directo
96
102
  # Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
97
103
  "WorkingDirectory=%s" % cwd.replace("%", "%%")]
98
104
  for name in env:
99
- if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
105
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*\Z", name):
100
106
  raise ServiceError("environment name %r is not a variable name" % name)
101
- lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
107
+ lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v), dollar=False) for k, v in sorted(env.items())]
102
108
  lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
103
109
  "[Install]", "WantedBy=default.target", ""]
104
110
  return "\n".join(lines)
@@ -237,17 +243,36 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
237
243
 
238
244
  ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
239
245
  SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
240
- instead of exiting into a respawn loop."""
246
+ instead of exiting into a respawn loop.
247
+
248
+ The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
249
+ not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
250
+ an argument shaped like a credential, and a control character in any string the plist carries."""
251
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
252
+ if not _LABEL.match(str(label)):
253
+ raise ServiceError("label must match %s" % _LABEL.pattern)
254
+ argv = [str(a) for a in program_arguments]
255
+ if not argv:
256
+ raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
257
+ if not exit_timeout > DRAIN_SECONDS:
258
+ raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
241
259
  env = dict(environment or {})
242
- for key in env:
243
- if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
260
+ for key, value in env.items():
261
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
244
262
  raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
263
+ if fabric_secrets.secret_value(str(value)):
264
+ raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
265
+ for arg in argv:
266
+ if fabric_secrets.secret_argument(arg):
267
+ raise ServiceError("An argument carries a credential; a plist never does (read it from the token file or a secret reference).")
268
+ for value in [str(label), str(working_directory), str(stdout_path)] + argv + [str(x) for pair in env.items() for x in pair]:
269
+ _plain(value, "a plist string")
245
270
  env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
246
- _no_marker_argument([str(a) for a in program_arguments])
271
+ _no_marker_argument(argv)
247
272
  env[SUPERVISOR_ENV] = "launchd"
248
273
  plist = {
249
274
  "Label": label,
250
- "ProgramArguments": list(program_arguments),
275
+ "ProgramArguments": argv,
251
276
  "WorkingDirectory": str(working_directory),
252
277
  "RunAtLoad": True,
253
278
  "KeepAlive": True,
@@ -287,7 +312,7 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
287
312
  return None
288
313
 
289
314
 
290
- _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*$')
315
+ _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
291
316
 
292
317
 
293
318
  def launchd_override(label: str) -> Optional[str]: