@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.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 (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  5. package/package.json +1 -1
  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 +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -6,6 +6,7 @@ only. The dashboard kit's fabric-button.js carries its own copy of the name and
6
6
 
7
7
  It reads as the contract's ECMAScript does (FAA-136): ECMAScript's whitespace (Python's `\\s` also takes U+0085 and
8
8
  U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII case folding, an ASCII word boundary."""
9
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
9
10
 
10
11
  from __future__ import annotations
11
12
 
@@ -29,8 +30,20 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
29
30
  JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
30
31
  _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
31
32
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
32
- SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
33
- r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
33
+ #: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
34
+ #: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
35
+ #: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
36
+ _KEY_BOUNDARY = (r"(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt\"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}"
37
+ r"|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)")
38
+ #: The provider keys' shapes, prefixes in their canonical case only (DEC-0151 item 7: `SK-` or `GHP_` is no key). An
39
+ #: `sk-` key holds, within 256 characters, a digit and an upper-case letter with 16 or more characters, or a digit with
40
+ #: 32 or more; the look-aheads are bounded to 255 characters and lazy, so a long run of prefixes reads in linear time.
41
+ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
42
+ r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})")
43
+ #: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
44
+ #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
45
+ #: read the matched text or its index.
46
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
34
47
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
35
48
  _BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
36
49
  re.IGNORECASE | re.ASCII)
@@ -142,7 +155,7 @@ def _percent_encode(text: str, also: str) -> str:
142
155
  return "".join(out)
143
156
 
144
157
 
145
- def _url_parts(value: str) -> Tuple[str, str, str]:
158
+ def url_parts(value: str) -> Tuple[str, str, str]:
146
159
  """The password, query and fragment of an absolute URL as WHATWG URL parsing splits them, read by hand: urlsplit
147
160
  refuses a `[` or `]` outside an IPv6 host (`<https>://u:p[w@h/`), where `new URL` reads the password (0.56.0 unit
148
161
  r7). The fragment starts at the first `#`, the query at the first `?` before it. A special scheme's authority starts
@@ -166,6 +179,9 @@ def _url_parts(value: str) -> Tuple[str, str, str]:
166
179
  _percent_encode(fragment, "`"))
167
180
 
168
181
 
182
+ _url_parts = url_parts # the name before FAA-250, kept for code that reached it
183
+
184
+
169
185
  def secret_url(value: str) -> bool:
170
186
  """Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
171
187
  password in its user information, or a query or fragment parameter whose name can only carry a credential and has
@@ -177,7 +193,7 @@ def secret_url(value: str) -> bool:
177
193
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
178
194
  if not _SCHEME.match(value):
179
195
  return False # not an absolute URL (WHATWG URL parsing refuses it)
180
- password, query, fragment = _url_parts(value)
196
+ password, query, fragment = url_parts(value)
181
197
  if password:
182
198
  return True
183
199
  for part in (query, fragment):
@@ -190,7 +206,10 @@ def secret_url(value: str) -> bool:
190
206
  _REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
191
207
  _REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
192
208
  _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
193
- _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % SECRET_VALUE.pattern, re.IGNORECASE | re.ASCII)
209
+ #: A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
210
+ #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
211
+ _REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
212
+ _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
194
213
  _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
195
214
 
196
215
 
@@ -197,6 +197,24 @@ from fabric_connections import ( # noqa: F401
197
197
  connections_rules,
198
198
  connections_view,
199
199
  )
200
+ from fabric_load import ( # noqa: F401 - the call queue and the load report (DEC-0104, FAA-289)
201
+ LOAD_EXTENSION_KEY,
202
+ LOAD_REPORT_TYPE,
203
+ QUEUE_FULL_MESSAGE,
204
+ QUEUE_LENGTH_MAX,
205
+ DEFAULT_RETRY_AFTER_SECONDS,
206
+ job_caller_key,
207
+ next_queued_job,
208
+ admit_job,
209
+ queue_status_message,
210
+ read_queue_position,
211
+ job_queue_rules,
212
+ load_report_rules,
213
+ load_report_problems,
214
+ load_view,
215
+ load_extension_rules,
216
+ load_extension,
217
+ )
200
218
  from fabric_descriptor import ( # noqa: F401
201
219
  DASHBOARD_EXTENSION_KEY,
202
220
  DASHBOARD_KIT,
@@ -210,6 +228,8 @@ from fabric_descriptor import ( # noqa: F401
210
228
  _NOT_LAUNCHD,
211
229
  _AUTH_HEADER,
212
230
  LoopbackHTTPServer,
231
+ PEER_CHECKS,
232
+ connection_refusal,
213
233
  descriptor_path,
214
234
  read_descriptors,
215
235
  validate_descriptor,
@@ -231,7 +251,9 @@ from fabric_descriptor import ( # noqa: F401
231
251
  fabric_button_presence,
232
252
  placement_of,
233
253
  remote_origin_problems,
254
+ port_listener_refusal,
234
255
  port_of,
256
+ port_owner,
235
257
  NO_SOURCE_REPOSITORY,
236
258
  NoSourceRepositoryWarning,
237
259
  names_repository,
@@ -316,6 +338,7 @@ from fabric_supervisors import ( # noqa: F401
316
338
  _launchctl,
317
339
  _domain,
318
340
  SYSTEM_DAEMONS,
341
+ USER_NAME_RULE,
319
342
  launchd_domain,
320
343
  launchd_loaded,
321
344
  launchd_pid,
@@ -47,6 +47,8 @@ DEFAULT_EXIT_TIMEOUT = 15
47
47
 
48
48
  _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
49
49
  _USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
50
+ #: The short-name rule in words, printed alike by both twins (FAA-286 item 13).
51
+ USER_NAME_RULE = "a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -"
50
52
 
51
53
  #: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
52
54
  SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
@@ -141,12 +143,40 @@ def _check(result: Any, what: str) -> None:
141
143
  raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
142
144
 
143
145
 
146
+ def _poll_installed(origin: str, service_id: Optional[str], instance: str, timeout: float,
147
+ read: Optional[Callable[[str], Any]], sleep: Callable[[float], None], clock: Callable[[], float]) -> Any:
148
+ """Poll the well-known answer at `origin` until this OS account's listener answers as `service_id`.`instance`, as
149
+ launchd_install does (the pinned contract's Install row: every supervisor polls, FAA-253). Another account's listener,
150
+ or one whose owner cannot be told, is never a successful install (DEC-0154 HA-2)."""
151
+ if not service_id:
152
+ raise ServiceError("polling the install at %s needs the service_id it must answer as" % origin)
153
+ read = read or well_known_of
154
+ deadline = clock() + max(0.0, timeout)
155
+ while True:
156
+ doc = read(origin)
157
+ foreign = doc.reason if isinstance(doc, ForeignUserAnswer) else None
158
+ if not foreign and _answers(doc, service_id, instance):
159
+ return doc
160
+ if clock() >= deadline:
161
+ break
162
+ sleep(0.5)
163
+ if foreign:
164
+ raise ServiceError("%s is not this service's: %s (DEC-0154 HA-2); the port is another OS account's or its owner "
165
+ "cannot be told." % (origin, foreign))
166
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
167
+
168
+
144
169
  def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
145
- system: str = sys.platform, force_enable: bool = False) -> Path:
170
+ system: str = sys.platform, force_enable: bool = False, origin: Optional[str] = None,
171
+ service_id: Optional[str] = None, instance: str = "default", timeout: float = 40.0,
172
+ fetch_well_known: Optional[Callable[[str], Any]] = None, sleep: Callable[[float], None] = time.sleep,
173
+ clock: Callable[[], float] = time.monotonic) -> Path:
146
174
  """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
147
175
 
148
176
  The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
149
- the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
177
+ the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on).
178
+ With ``origin`` (and ``service_id``), it then polls the well-known answer as ``launchd_install`` does, until this
179
+ account's listener answers within ``timeout`` (FAA-253); ``fetch_well_known(origin)`` reads it as ``well_known_of``."""
150
180
  if not system.startswith("linux"):
151
181
  raise ServiceError("systemd is the supervisor on Linux only")
152
182
  if not _UNIT.match(unit):
@@ -163,6 +193,8 @@ def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, ru
163
193
  return path
164
194
  _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
165
195
  _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
196
+ if origin is not None:
197
+ _poll_installed(origin, service_id, instance, timeout, fetch_well_known, sleep, clock)
166
198
  return path
167
199
 
168
200
 
@@ -227,9 +259,13 @@ _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Ena
227
259
 
228
260
 
229
261
  def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
230
- force_enable: bool = False) -> None:
262
+ force_enable: bool = False, origin: Optional[str] = None, service_id: Optional[str] = None,
263
+ instance: str = "default", timeout: float = 40.0, fetch_well_known: Optional[Callable[[str], Any]] = None,
264
+ sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic) -> Any:
231
265
  """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
232
- definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
266
+ definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``.
267
+ With ``origin`` (and ``service_id``), it then polls the well-known answer as ``launchd_install`` does and returns it
268
+ (FAA-253); without, it returns None as before."""
233
269
  if system != "win32":
234
270
  raise ServiceError("Task Scheduler is the supervisor on Windows only")
235
271
  query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
@@ -248,6 +284,9 @@ def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subproce
248
284
  if existed:
249
285
  run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
250
286
  _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
287
+ if origin is not None:
288
+ return _poll_installed(origin, service_id, instance, timeout, fetch_well_known, sleep, clock)
289
+ return None
251
290
 
252
291
 
253
292
  def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
@@ -336,6 +375,9 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
336
375
  session; a ``gui/<uid>`` job starts only while its user is logged in."""
337
376
  if domain not in (None, "gui", "system"):
338
377
  raise ServiceError("domain must be gui or system")
378
+ if plist_path is not None and re.match(r"^(?:system|gui)(?:/[0-9]*)?\Z", str(plist_path)):
379
+ raise ServiceError("%s is a launchd domain, not a plist path: pass domain=%r (FAA-286)"
380
+ % (plist_path, "system" if str(plist_path) == "system" else "gui"))
339
381
  if domain is None and plist_path is not None:
340
382
  folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
341
383
  domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
@@ -347,6 +389,39 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
347
389
  return _domain()
348
390
 
349
391
 
392
+ def _in_daemons(plist_path: Path) -> bool:
393
+ folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
394
+ return folder.lower() == str(SYSTEM_DAEMONS).lower()
395
+
396
+
397
+ def _pair_problem(plist_path: Path, domain: Optional[str]) -> Optional[str]:
398
+ """A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd
399
+ also loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root).
400
+ Another folder is allowed for a system job, as the tests' and a staging install's are."""
401
+ if domain == "system" and Path(os.path.abspath(str(plist_path))).parent.name.lower() == "launchagents":
402
+ return ("A LaunchDaemon's plist lives in %s: %s is in a LaunchAgents folder, which launchd also loads into "
403
+ "gui/<uid> at login, so the job would run twice." % (SYSTEM_DAEMONS, plist_path))
404
+ if domain == "gui" and _in_daemons(plist_path):
405
+ return ("%s is in %s, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in "
406
+ "~/Library/LaunchAgents." % (plist_path, SYSTEM_DAEMONS))
407
+ return None
408
+
409
+
410
+ def _leftover_agent(label: str, user_name: str) -> Optional[Path]:
411
+ """A LaunchAgent of ``label`` in ``user_name``'s ~/Library/LaunchAgents, which would run a second copy at its login
412
+ (FAA-286 item 6)."""
413
+ try:
414
+ import pwd
415
+ home = Path(pwd.getpwnam(user_name).pw_dir)
416
+ except (ImportError, KeyError, ValueError):
417
+ return None
418
+ try:
419
+ agents = sorted((home / "Library" / "LaunchAgents").glob("*.plist"))
420
+ except OSError:
421
+ return None
422
+ return next((p for p in agents if _plist_field(p, "Label") == label), None)
423
+
424
+
350
425
  def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
351
426
  """A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
352
427
  if domain != "system" or os.geteuid() == 0:
@@ -368,13 +443,16 @@ def launchd_loaded(label: str, domain: Optional[str] = None) -> bool:
368
443
  return _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label)).returncode == 0
369
444
 
370
445
 
371
- _PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
446
+ _PID_ROW = re.compile(r"^\s*pid\s*=\s*([0-9]+)\s*\Z") # ASCII digits, as Node reads them (FAA-273)
372
447
 
373
448
 
374
449
  def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
375
450
  """The running pid of this account's launchd job ``label`` (with ``domain="system"``, of the LaunchDaemon), or
376
451
  None (not running, or not readable). Reading needs no root."""
377
- result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
452
+ try:
453
+ result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
454
+ except subprocess.TimeoutExpired:
455
+ return None # a launchctl that hangs is a pid not read, never an error out of an install's poll (FAA-273)
378
456
  if result.returncode != 0:
379
457
  return None
380
458
  for line in (result.stdout or "").splitlines():
@@ -660,6 +738,9 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
660
738
  read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
661
739
  read_job = fetch_job_well_known or job_well_known_of
662
740
  plist_path = Path(plist_path)
741
+ pair = _pair_problem(plist_path, domain)
742
+ if pair:
743
+ raise ServiceError(pair)
663
744
  dom = launchd_domain(plist_path, domain)
664
745
  which = "system" if dom == "system" else None
665
746
  _need_root(dom, label, plist_path, True)
@@ -673,8 +754,8 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
673
754
  "the job's pid and uid (FAA-278); %s is not." % origin)
674
755
  try:
675
756
  named = plistlib.loads(plist_bytes).get("UserName")
676
- except Exception: # a plist that does not parse fails plutil -lint below
677
- named = None
757
+ except Exception as exc: # named as what it is, never as a missing UserName (FAA-286 item 16)
758
+ raise ServiceError("The plist does not parse (%s): write it with launchd_plist." % type(exc).__name__)
678
759
  named = named if isinstance(named, str) else None # a UserName that is not a string names no account
679
760
  if named:
680
761
  _refuse_held_label(label, plist_path)
@@ -691,7 +772,7 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
691
772
  "it), or uninstall that one as root first." % (plist_path, installed))
692
773
  if named and not _USER_NAME.match(named):
693
774
  raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
694
- % (named, _USER_NAME.pattern))
775
+ % (named, USER_NAME_RULE))
695
776
  job_uid = _account_uid(named) if named else None
696
777
  if named and job_uid is None:
697
778
  raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
@@ -704,6 +785,12 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
704
785
  raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
705
786
  "home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
706
787
  "launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
788
+ leftover = _leftover_agent(label, named)
789
+ if leftover is not None:
790
+ raise ServiceError("%s is a LaunchAgent of label %s: it would start a second copy at %s's login beside this "
791
+ "LaunchDaemon. Remove it first (launchd_uninstall as %s; lifecycle reference, The system "
792
+ "domain)." % (leftover, label, named, named))
793
+ existed = plist_path.exists()
707
794
  atomic_write(plist_path, plist_bytes, 0o644)
708
795
  lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
709
796
  if lint.returncode != 0:
@@ -731,6 +818,16 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
731
818
  "headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
732
819
  "this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
733
820
  "domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
821
+ if dom == "system":
822
+ # a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
823
+ if not existed:
824
+ try:
825
+ plist_path.unlink()
826
+ except OSError:
827
+ pass
828
+ raise ServiceError("launchctl bootstrap failed: %s; %s" % (last, "the plist this call wrote was removed"
829
+ if not plist_path.exists() else "%s stays and loads at the next boot: remove it as root"
830
+ % plist_path))
734
831
  raise ServiceError("launchctl bootstrap failed: %s" % last)
735
832
  deadline = time.time() + timeout
736
833
  if dom == "system":
@@ -794,6 +891,10 @@ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, pu
794
891
  "remove the data as that user.")
795
892
  if dom == "system":
796
893
  _refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
894
+ holds = _plist_field(Path(plist_path), "Label") if Path(plist_path).exists() else None
895
+ if holds is not None and holds != label: # FAA-286 item 9: booting out one job and unlinking another's plist
896
+ raise ServiceError("%s holds launchd label %s, not %s: nothing was removed. Name the plist of %s, or uninstall %s "
897
+ "with its own label." % (plist_path, holds, label, label, holds))
797
898
  dirs = instance_dirs(str(service_id), instance) if purge else {}
798
899
  target = "%s/%s" % (dom, label)
799
900
  _launchctl("bootout", target)
@@ -137,7 +137,7 @@ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[D
137
137
  out = []
138
138
  for key in sorted(projects):
139
139
  row = projects[key]
140
- runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
140
+ runs = [_close_run(run) for _, run in sorted(row.pop("_runs").items())]
141
141
  row = _close_totals(row)
142
142
  if runs:
143
143
  row["taskRuns"] = runs
@@ -167,6 +167,17 @@ def _round_usd(value: float) -> float:
167
167
  return whole / 1e6
168
168
 
169
169
 
170
+ def _close_run(run: Dict[str, Any]) -> Dict[str, Any]:
171
+ """A task run's totals, its cost rounded as `_round_usd` rounds but never above the exact cost: each run and its
172
+ project round apart, so many runs rounding up summed past the project and failed FAC-SEM-025 (board request
173
+ f5753c5a). Rounded this way the runs' sum stays within the project's rounded cost by construction."""
174
+ exact = run["_cost"]
175
+ run = _close_totals(run)
176
+ if run["costUsd"] is not None and run["costUsd"] > exact:
177
+ run["costUsd"] = _round_usd(run["costUsd"] - 0.000001)
178
+ return run
179
+
180
+
170
181
  def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
171
182
  cost = row.pop("_cost")
172
183
  row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else _round_usd(cost)