@passioncode-ai/fabric-agent-adapter 0.77.0 → 0.79.1

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 (74) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +31 -11
  3. package/SECURITY.md +17 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +15 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +27 -39
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +74 -3
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +339 -26
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +367 -27
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +323 -25
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -6
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  62. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  63. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  64. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  65. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  66. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  67. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +30 -39
  68. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  69. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -4,6 +4,7 @@ removed without touching what the operator turned off; release pruning (LC-15).
4
4
  from __future__ import annotations
5
5
 
6
6
  import http.client
7
+ import ipaddress
7
8
  import json
8
9
  import os
9
10
  import plistlib
@@ -45,6 +46,10 @@ DEFAULT_EXIT_TIMEOUT = 15
45
46
 
46
47
 
47
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
+ _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
+
51
+ #: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
52
+ SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
48
53
 
49
54
 
50
55
  def _no_marker_argument(argv: Sequence[str]) -> None:
@@ -255,7 +260,7 @@ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, syste
255
260
 
256
261
  def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
257
262
  stdout_path: Path, environment: Optional[Dict[str, str]] = None,
258
- exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
263
+ exit_timeout: int = DEFAULT_EXIT_TIMEOUT, user_name: Optional[str] = None) -> bytes:
259
264
  """RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
260
265
 
261
266
  ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
@@ -264,7 +269,10 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
264
269
 
265
270
  The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
266
271
  not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
267
- an argument shaped like a credential, and a control character in any string the plist carries."""
272
+ an argument shaped like a credential, and a control character in any string the plist carries.
273
+
274
+ ``user_name`` writes ``UserName``, the account a LaunchDaemon (system domain, FAA-278) runs as; it must be a macOS
275
+ short name, and it is meaningful only for a plist installed in ``/Library/LaunchDaemons``."""
268
276
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
269
277
  if not _LABEL.match(str(label)):
270
278
  raise ServiceError("label must match %s" % _LABEL.pattern)
@@ -273,6 +281,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
273
281
  raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
274
282
  if not exit_timeout > DRAIN_SECONDS:
275
283
  raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
284
+ if user_name is not None and not _USER_NAME.match(str(user_name)):
285
+ raise ServiceError("UserName must be a macOS short name matching %s" % _USER_NAME.pattern)
276
286
  env = dict(environment or {})
277
287
  for key, value in env.items():
278
288
  if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
@@ -303,6 +313,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
303
313
  "StandardErrorPath": str(stdout_path),
304
314
  "EnvironmentVariables": env,
305
315
  }
316
+ if user_name is not None:
317
+ plist["UserName"] = str(user_name)
306
318
  return plistlib.dumps(plist)
307
319
 
308
320
 
@@ -317,16 +329,52 @@ def _domain() -> str:
317
329
  return "gui/%d" % os.getuid()
318
330
 
319
331
 
320
- def launchd_loaded(label: str) -> bool:
321
- return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
332
+ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = None) -> str:
333
+ """The launchd domain a job lives in: ``system`` for a LaunchDaemon (``domain="system"``, or a plist in
334
+ ``/Library/LaunchDaemons``), else this user's ``gui/<uid>`` (FAA-278). A LaunchDaemon starts at boot, before any
335
+ login, so it is the form for a headless Mac that a FileVault restart (``fdesetup authrestart``) leaves with no GUI
336
+ session; a ``gui/<uid>`` job starts only while its user is logged in."""
337
+ if domain not in (None, "gui", "system"):
338
+ raise ServiceError("domain must be gui or system")
339
+ if domain is None and plist_path is not None:
340
+ folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
341
+ domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
342
+ if domain == "system":
343
+ if sys.platform != "darwin":
344
+ raise ServiceError("launchd is the supervisor on macOS only; use systemd_install on Linux and task_install on "
345
+ "Windows.")
346
+ return "system"
347
+ return _domain()
348
+
349
+
350
+ def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
351
+ """A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
352
+ if domain != "system" or os.geteuid() == 0:
353
+ return
354
+ if install:
355
+ raise ServiceError("A LaunchDaemon (launchd's system domain) is installed by root, and this process is not "
356
+ "root: run launchd_install as root (the release, descriptor and token stay its UserName's, "
357
+ "lifecycle reference, The system domain), or as root write %s and run "
358
+ "`launchctl bootstrap system %s`. Status and pid read without root." % (plist_path, plist_path))
359
+ raise ServiceError("A LaunchDaemon (launchd's system domain) is removed by root, and this process is not root: run "
360
+ "launchd_uninstall as root, or as root run `launchctl bootout system/%s`, remove %s and run "
361
+ "`launchctl enable system/%s` (so a later install is not kept off). Status and pid read without "
362
+ "root." % (label, plist_path, label))
363
+
364
+
365
+ def launchd_loaded(label: str, domain: Optional[str] = None) -> bool:
366
+ """Whether launchd has the job ``label`` loaded in this user's ``gui/<uid>`` domain, or with ``domain="system"`` in
367
+ the system domain (FAA-278); reading needs no root."""
368
+ return _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label)).returncode == 0
322
369
 
323
370
 
324
371
  _PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
325
372
 
326
373
 
327
- def launchd_pid(label: str) -> Optional[int]:
328
- """The running pid of this account's launchd job ``label``, or None (not running, or not readable)."""
329
- result = _launchctl("print", "%s/%s" % (_domain(), label))
374
+ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
375
+ """The running pid of this account's launchd job ``label`` (with ``domain="system"``, of the LaunchDaemon), or
376
+ None (not running, or not readable). Reading needs no root."""
377
+ result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
330
378
  if result.returncode != 0:
331
379
  return None
332
380
  for line in (result.stdout or "").splitlines():
@@ -336,6 +384,29 @@ def launchd_pid(label: str) -> Optional[int]:
336
384
  return None
337
385
 
338
386
 
387
+ def _loopback_host(host: Optional[str]) -> bool:
388
+ """Whether `host` (urlsplit's hostname: lower-case, no brackets) names this machine: localhost, 127/8 or ::1."""
389
+ if not host:
390
+ return False
391
+ if host == "localhost":
392
+ return True
393
+ try:
394
+ return ipaddress.ip_address(host).is_loopback
395
+ except ValueError:
396
+ return False
397
+
398
+
399
+ class ForeignUserAnswer:
400
+ """A refused listener (DEC-0154 HA-2), as `well_known_of` answers it: never a document, whatever keys a service's JSON
401
+ carries. `reason` starts with `foreign-user` or `table-restricted` (the Node twin's class, FAA-259)."""
402
+
403
+ def __init__(self, reason: str) -> None:
404
+ self.reason = reason
405
+
406
+ def __repr__(self) -> str:
407
+ return "ForeignUserAnswer(%r)" % self.reason
408
+
409
+
339
410
  def _read_well_known(origin: str, timeout: float = 2.0,
340
411
  vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
341
412
  """(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
@@ -344,10 +415,16 @@ def _read_well_known(origin: str, timeout: float = 2.0,
344
415
  table hides other processes. `vouch_pid`, the pid of a launchd job this
345
416
  caller started, proves the listener when the socket table hides other processes (FAA-267)."""
346
417
  parts = urllib.parse.urlsplit(origin)
347
- if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
348
- conn = _po.CheckedHTTPConnection("127.0.0.1", parts.port, timeout=timeout, vouch_pid=vouch_pid)
418
+ try:
419
+ port = parts.port or (80 if parts.scheme == "http" else None) # no port is http's 80, as in the Node twin
420
+ except ValueError: # a port out of range
421
+ port = None
422
+ if parts.scheme == "http" and port and _loopback_host(parts.hostname):
423
+ # every loopback spelling (127.0.0.1, another 127/8 address, localhost, [::1]) goes on a checked connection,
424
+ # never the unchecked way (FAA-257)
425
+ conn = _po.CheckedHTTPConnection(parts.hostname, port, timeout=timeout, vouch_pid=vouch_pid)
349
426
  try:
350
- conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % parts.port})
427
+ conn.request("GET", "/.well-known/fabric-service", headers={"Host": parts.netloc.rpartition("@")[2]})
351
428
  response = conn.getresponse()
352
429
  return (json.loads(response.read().decode()) if response.status == 200 else None), None
353
430
  except _po.ListenerRefused as exc:
@@ -364,22 +441,137 @@ def _read_well_known(origin: str, timeout: float = 2.0,
364
441
  return None, None
365
442
 
366
443
 
444
+ def _job_listens(pid: int, port: int) -> bool:
445
+ """Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
446
+ (`lsof -p`), which a restricted socket table does not hide. Used only for a LaunchDaemon root just bootstrapped,
447
+ whose pid launchd itself named (FAA-278)."""
448
+ if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
449
+ return False
450
+ try:
451
+ out = _po._run([_po.LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
452
+ except _po.TableUnreadable:
453
+ return False
454
+ return any(host == "127.0.0.1" and p == port for host, p in _po.parse_lsof_listeners(out)) # exact bind, as RT-3
455
+
456
+
457
+ def _account_uid(user_name: str) -> Optional[int]:
458
+ """The uid of account ``user_name`` on this machine, or None when it has no such account. Only a short name: macOS
459
+ getpwnam also answers an upper-case name or the full name, which launchd's UserName does not take."""
460
+ if not isinstance(user_name, str) or not _USER_NAME.match(user_name):
461
+ return None
462
+ try:
463
+ import pwd
464
+ return pwd.getpwnam(user_name).pw_uid
465
+ except (ImportError, KeyError, ValueError):
466
+ return None
467
+
468
+
469
+ def _pid_uid(pid: int) -> Optional[int]:
470
+ """The uid process ``pid`` runs as (``ps -o uid= -p``), or None when it is gone or cannot be read."""
471
+ try:
472
+ return _po.parse_ps_uid(_po._run([_po.PS, "-o", "uid=", "-p", str(pid)]))
473
+ except _po.TableUnreadable:
474
+ return None
475
+
476
+
477
+ def _positive(value: Any) -> bool:
478
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
479
+
480
+
481
+ class _JobConnection(http.client.HTTPConnection):
482
+ """A connection whose server end must be launchd's job `job_pid`, running as `job_uid`, its UserName's uid (a
483
+ LaunchDaemon runs as its UserName, so the installer, root, reads it as another account's: the job's own pid and
484
+ uid are the proof, FAA-278; contract DEC-0161, proposed: RT-3 extended to launchd's system domain)."""
485
+
486
+ def __init__(self, host: str, port: int, job_pid: int, timeout: float, job_uid: Optional[int] = None) -> None:
487
+ if not isinstance(job_pid, int) or isinstance(job_pid, bool) or job_pid <= 1:
488
+ raise ServiceError("A LaunchDaemon's answer is read only on its job's pid, a positive number.")
489
+ if not _positive(job_uid):
490
+ raise ServiceError("A LaunchDaemon's answer is read only from its UserName's uid, a number.")
491
+ super().__init__(host, port, timeout=timeout)
492
+ self.job_pid = job_pid
493
+ self.job_uid = job_uid
494
+
495
+ def connect(self) -> None:
496
+ super().connect()
497
+ owner = _po.server_end(self.sock)
498
+ restricted = owner.kind == _po.UNKNOWN and (owner.reason or "").startswith(_po.TABLE_RESTRICTED)
499
+ if owner.pid == self.job_pid and not _job_listens(self.job_pid, self.port):
500
+ self.close()
501
+ # The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
502
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d is not shown listening on exactly "
503
+ "127.0.0.1:%d by lsof (a wildcard is never vouched for); nothing was sent"
504
+ % (self.job_pid, self.port))
505
+ if owner.pid == self.job_pid or (restricted and _job_listens(self.job_pid, self.port)):
506
+ runs_as = _pid_uid(self.job_pid)
507
+ if runs_as == self.job_uid:
508
+ return
509
+ self.close()
510
+ # Not the restricted owner: the pid was proved, only its uid is wrong, and the error says so.
511
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d runs as uid %s, not its UserName's "
512
+ "uid %d; nothing was sent" % (self.job_pid, runs_as, self.job_uid))
513
+ self.close()
514
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the server on port %d is not the LaunchDaemon's pid %d (%s): "
515
+ "another process holds the port, or the job's program starts the server as a child "
516
+ "(start it with exec); nothing was sent"
517
+ % (self.port, self.job_pid, owner.reason or owner.kind), owner)
518
+
519
+
520
+ def _read_job_well_known(origin: str, job_pid: int, job_uid: Optional[int] = None,
521
+ timeout: float = 2.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
522
+ """As `_read_well_known`, for a LaunchDaemon: a loopback origin is read only when launchd's job `job_pid`, running as
523
+ `job_uid`, accepted the connection; (the answer or None, why the listener was refused or None)."""
524
+ try:
525
+ port = urllib.parse.urlsplit(origin).port
526
+ except ValueError: # a port out of range
527
+ port = None
528
+ if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
529
+ raise ServiceError("A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid "
530
+ "prove it; %s is not." % origin)
531
+ conn = _JobConnection("127.0.0.1", port, job_pid, timeout, job_uid)
532
+ try:
533
+ conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % port})
534
+ response = conn.getresponse()
535
+ return (json.loads(response.read().decode()) if response.status == 200 else None), None
536
+ except _po.ListenerRefused as exc:
537
+ restricted = exc.owner is not None and (exc.owner.reason or "").startswith(_po.TABLE_RESTRICTED)
538
+ return None, "%s: %s" % (_po.TABLE_RESTRICTED if restricted else "foreign-user", exc)
539
+ except (OSError, ValueError, http.client.HTTPException):
540
+ return None, None
541
+ finally:
542
+ conn.close()
543
+
544
+
367
545
  def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
368
546
  """The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
369
- OS account's process (DEC-0154 HA-2)."""
547
+ OS account's process (DEC-0154 HA-2). `well_known_of` tells the last two apart."""
370
548
  return _read_well_known(origin, timeout)[0]
371
549
 
372
550
 
551
+ def well_known_of(origin: str, timeout: float = 2.0, vouch_pid: Optional[int] = None) -> Any:
552
+ """The well-known answer at `origin` as the Node `wellKnownOf` gives it (FAA-259): the document; None when nothing
553
+ answers or the answer is not JSON (down); a `ForeignUserAnswer` when the listener is another OS account's process or
554
+ its owner cannot be told. So a status reads HA-2's order: down, foreign-user, foreign, status."""
555
+ doc, why = _read_well_known(origin, timeout, vouch_pid=vouch_pid)
556
+ return ForeignUserAnswer(why) if why else doc
557
+
558
+
559
+ def job_well_known_of(origin: str, job_pid: int, job_uid: Optional[int] = None, timeout: float = 2.0) -> Any:
560
+ """As `well_known_of`, for a LaunchDaemon whose job `job_pid` (running as `job_uid`) must hold the listener."""
561
+ doc, why = _read_job_well_known(origin, job_pid, job_uid)
562
+ return ForeignUserAnswer(why) if why else doc
563
+
564
+
373
565
  _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
374
566
 
375
567
 
376
- def launchd_override(label: str) -> Optional[str]:
568
+ def launchd_override(label: str, domain: Optional[str] = None) -> Optional[str]:
377
569
  """The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
378
570
  none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
379
571
 
380
- Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
572
+ Reads ``launchctl print-disabled gui/<uid>`` (``system`` with ``domain="system"``); macOS 14+ prints ``=> disabled|enabled``,
381
573
  older releases ``=> true|false`` (true means disabled)."""
382
- result = _launchctl("print-disabled", _domain())
574
+ result = _launchctl("print-disabled", launchd_domain(domain=domain))
383
575
  if result.returncode != 0:
384
576
  return "unknown"
385
577
  for line in result.stdout.splitlines():
@@ -389,9 +581,65 @@ def launchd_override(label: str) -> Optional[str]:
389
581
  return None
390
582
 
391
583
 
584
+ _PATH_ROW = re.compile(r"^\s*path\s*=\s*(.+?)\s*\Z")
585
+
586
+
587
+ def _plist_field(path: Path, key: str) -> Optional[str]:
588
+ try:
589
+ value = plistlib.loads(Path(path).read_bytes()).get(key)
590
+ except Exception: # unreadable or not a plist: it claims nothing
591
+ return None
592
+ return value if isinstance(value, str) else None
593
+
594
+
595
+ def _same_file(a: Any, b: Any) -> bool:
596
+ """One file, whatever the case of its path (APFS ignores case, realpath keeps it)."""
597
+ try:
598
+ return os.path.samefile(str(a), str(b))
599
+ except OSError:
600
+ return os.path.realpath(str(a)) == os.path.realpath(str(b))
601
+
602
+
603
+ def _label_holders(label: str, plist_path: Path) -> List[Path]:
604
+ """Other plists that hold launchd label ``label`` in the system domain (FAA-278): the plist launchd loaded the job
605
+ from, when it is not ``plist_path``, and any plist of that label beside ``plist_path``. Reading needs no root."""
606
+ found: List[Path] = []
607
+ result = _launchctl("print", "system/%s" % label)
608
+ if result.returncode == 0:
609
+ for line in (result.stdout or "").splitlines():
610
+ hit = _PATH_ROW.match(line)
611
+ if hit:
612
+ found.append(Path(hit.group(1)))
613
+ break
614
+ try:
615
+ siblings = sorted(Path(plist_path).parent.glob("*.plist"))
616
+ except OSError:
617
+ siblings = []
618
+ found += [p for p in siblings if _plist_field(p, "Label") == label]
619
+ return [p for p in dict.fromkeys(found) if not _same_file(p, plist_path)]
620
+
621
+
622
+ def _answers(doc: Any, service_id: str, instance: str) -> bool:
623
+ """Whether a well-known answer names this service and instance; any other JSON is no answer."""
624
+ service = doc.get("service") if isinstance(doc, dict) else None
625
+ return isinstance(service, dict) and service.get("id") == service_id and service.get("instance") == instance
626
+
627
+
628
+ def _refuse_held_label(label: str, plist_path: Path) -> None:
629
+ holders = _label_holders(label, plist_path)
630
+ if holders:
631
+ owner = _plist_field(holders[0], "UserName")
632
+ raise ServiceError("Label %s is already held by %s%s: the system domain is shared by every account of this "
633
+ "Mac, so a label is the machine's and one plist holds it. Choose a label of your own (for "
634
+ "example with the account in it), or remove that LaunchDaemon as root first."
635
+ % (label, holders[0], " (%s's)" % owner if owner else ""))
636
+
637
+
392
638
  def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
393
639
  service_id: str, instance: str = "default", timeout: float = 40.0,
394
- force_enable: bool = False) -> Dict[str, Any]:
640
+ force_enable: bool = False, domain: Optional[str] = None,
641
+ fetch_well_known: Optional[Callable[..., Any]] = None,
642
+ fetch_job_well_known: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
395
643
  """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
396
644
  poll the well-known document until it answers with this identity.
397
645
 
@@ -399,38 +647,119 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
399
647
  label. A label the operator disabled stays disabled and unloaded — the plist is still
400
648
  rewritten, so the new release is what starts once they turn it back on — and the answer is
401
649
  ``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
402
- ``force_enable`` is for an operator who asked for the service to be switched on."""
650
+ ``force_enable`` is for an operator who asked for the service to be switched on.
651
+
652
+ A plist in ``/Library/LaunchDaemons`` (or ``domain="system"``) is a LaunchDaemon in launchd's system domain, started
653
+ at boot with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the
654
+ answer counts only when launchd's own job accepted the connection (its ``UserName``, not the installer, owns it).
655
+ A ``gui/<uid>`` bootstrap with no GUI session (SSH, a headless Mac after a FileVault restart) says so.
656
+
657
+ ``fetch_well_known(origin, vouch_pid=...)`` and ``fetch_job_well_known(origin, job_pid, job_uid)`` read the answer as
658
+ ``well_known_of`` and ``job_well_known_of`` do (the document, None, or a ``ForeignUserAnswer``); a caller may inject
659
+ its own, as the Node ``launchdInstall`` takes ``fetchWellKnown`` (FAA-259)."""
660
+ read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
661
+ read_job = fetch_job_well_known or job_well_known_of
403
662
  plist_path = Path(plist_path)
663
+ dom = launchd_domain(plist_path, domain)
664
+ which = "system" if dom == "system" else None
665
+ _need_root(dom, label, plist_path, True)
666
+ if dom == "system":
667
+ try:
668
+ port = urllib.parse.urlsplit(origin).port
669
+ except ValueError: # a port out of range
670
+ port = None
671
+ if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
672
+ raise ServiceError("A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by "
673
+ "the job's pid and uid (FAA-278); %s is not." % origin)
674
+ try:
675
+ named = plistlib.loads(plist_bytes).get("UserName")
676
+ except Exception: # a plist that does not parse fails plutil -lint below
677
+ named = None
678
+ named = named if isinstance(named, str) else None # a UserName that is not a string names no account
679
+ if named:
680
+ _refuse_held_label(label, plist_path)
681
+ if named and plist_path.exists():
682
+ installed_label = _plist_field(plist_path, "Label")
683
+ if installed_label is not None and installed_label != label:
684
+ raise ServiceError("%s holds launchd label %s, not %s: writing over it would leave that job loaded from a "
685
+ "plist that no longer describes it. Uninstall it as root first, or write this one at "
686
+ "%s/%s.plist." % (plist_path, installed_label, label, SYSTEM_DAEMONS, label))
687
+ installed = _plist_field(plist_path, "UserName")
688
+ if installed and installed != named:
689
+ raise ServiceError("%s is %s's LaunchDaemon: the system domain is shared by every account of this Mac, so "
690
+ "a label is the machine's. Choose a label of your own (for example with the account in "
691
+ "it), or uninstall that one as root first." % (plist_path, installed))
692
+ if named and not _USER_NAME.match(named):
693
+ raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
694
+ % (named, _USER_NAME.pattern))
695
+ job_uid = _account_uid(named) if named else None
696
+ if named and job_uid is None:
697
+ raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
698
+ "whose Fabric and agents call the service." % named)
699
+ if job_uid == 0:
700
+ raise ServiceError("UserName %s is root: the job would run as root, keep its data in root's home, and every "
701
+ "client refuses root's listener (DEC-0154 HA-2). Name the account whose Fabric and agents "
702
+ "call the service." % named)
703
+ if not named:
704
+ raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
705
+ "home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
706
+ "launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
404
707
  atomic_write(plist_path, plist_bytes, 0o644)
405
708
  lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
406
709
  if lint.returncode != 0:
407
710
  raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
408
- target = "%s/%s" % (_domain(), label)
409
- override = launchd_override(label)
711
+ target = "%s/%s" % (dom, label)
712
+ override = launchd_override(label, which)
410
713
  if force_enable or override is None:
411
714
  _launchctl("enable", target)
412
715
  elif override == "disabled":
413
- return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
716
+ return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label, which)}
414
717
  _launchctl("bootout", target)
415
718
  deadline = time.time() + 15
416
- while launchd_loaded(label) and time.time() < deadline:
719
+ while launchd_loaded(label, which) and time.time() < deadline:
417
720
  time.sleep(0.25)
418
721
  last = ""
419
722
  for _ in range(5):
420
- result = _launchctl("bootstrap", _domain(), str(plist_path))
723
+ result = _launchctl("bootstrap", dom, str(plist_path))
421
724
  if result.returncode == 0:
422
725
  break
423
726
  last = (result.stderr or result.stdout).strip()
424
727
  time.sleep(1.0)
425
728
  else:
729
+ if dom != "system" and re.search(r":\s*125:|Domain does not support|Could not find domain", last):
730
+ raise ServiceError("launchctl bootstrap failed: %s. This user has no GUI login session (an SSH session, or a "
731
+ "headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
732
+ "this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
733
+ "domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
426
734
  raise ServiceError("launchctl bootstrap failed: %s" % last)
427
735
  deadline = time.time() + timeout
736
+ if dom == "system":
737
+ foreign = None
738
+ while time.time() < deadline:
739
+ job = launchd_pid(label, "system")
740
+ doc = read_job(origin, job, job_uid) if job else None
741
+ why = doc.reason if isinstance(doc, ForeignUserAnswer) else None
742
+ foreign = why or foreign # the last refusal stays the reason while launchd shows no pid
743
+ if not why and _answers(doc, service_id, instance):
744
+ return doc
745
+ time.sleep(0.5)
746
+ loaded = ("; the job stays loaded and its plist starts it again at boot: as root, `launchctl bootout system/%s` "
747
+ "and removing %s (or launchd_uninstall) take it away" % (label, plist_path))
748
+ if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
749
+ raise ServiceError("%s could not be proved this LaunchDaemon's: %s; the job's pid did not show the listener: "
750
+ "start the server with exec (no wrapper child), or read the job's log%s."
751
+ % (origin, foreign, loaded))
752
+ if foreign:
753
+ raise ServiceError("%s is not this LaunchDaemon's: %s%s." % (origin, foreign, loaded))
754
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log%s."
755
+ % (origin, service_id, instance, timeout, loaded))
428
756
  foreign = vouch = None
429
757
  while time.time() < deadline:
430
- doc, foreign = _read_well_known(origin, vouch_pid=vouch)
758
+ doc = read_answer(origin, vouch_pid=vouch)
759
+ foreign = doc.reason if isinstance(doc, ForeignUserAnswer) else None
431
760
  if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
432
761
  vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
433
- if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
762
+ if not foreign and _answers(doc, service_id, instance):
434
763
  return doc
435
764
  time.sleep(0.5)
436
765
  if foreign:
@@ -445,20 +774,31 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
445
774
 
446
775
 
447
776
  def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
448
- service_id: Optional[str] = None, instance: str = "default") -> Dict[str, Any]:
777
+ service_id: Optional[str] = None, instance: str = "default",
778
+ domain: Optional[str] = None) -> Dict[str, Any]:
449
779
  """Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
450
780
  the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
451
781
  override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
452
782
  unless ``purge`` (then ``service_id`` names whose data, logs and cache go, and ``instance`` which
453
783
  instance's: ``instance_dirs``, so one context's purge never reaches another's, FAA-125). A job still
454
- loaded at ``timeout`` raises, leaving the plist and data in place."""
784
+ loaded at ``timeout`` raises, leaving the plist and data in place. A LaunchDaemon (a plist in
785
+ ``/Library/LaunchDaemons``, or ``domain="system"``) is removed only by root, and its data belongs to its UserName,
786
+ so ``purge`` is refused for it: remove the data as that user (FAA-278)."""
455
787
  if purge and not service_id:
456
788
  raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
789
+ dom = launchd_domain(Path(plist_path), domain)
790
+ which = "system" if dom == "system" else None
791
+ _need_root(dom, label, Path(plist_path), False)
792
+ if purge and dom == "system":
793
+ raise ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then "
794
+ "remove the data as that user.")
795
+ if dom == "system":
796
+ _refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
457
797
  dirs = instance_dirs(str(service_id), instance) if purge else {}
458
- target = "%s/%s" % (_domain(), label)
798
+ target = "%s/%s" % (dom, label)
459
799
  _launchctl("bootout", target)
460
800
  deadline = time.monotonic() + timeout
461
- while launchd_loaded(label):
801
+ while launchd_loaded(label, which):
462
802
  if time.monotonic() >= deadline:
463
803
  raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
464
804
  time.sleep(0.1)