@passioncode-ai/fabric-agent-adapter 0.76.0 → 0.78.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 (49) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +22 -6
  3. package/SECURITY.md +25 -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/skills/adapting-projects-to-fabric/SKILL.md +13 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +55 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +33 -44
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +55 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +4 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +325 -24
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +9 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +44 -15
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +310 -23
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +209 -9
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +15 -4
  33. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +55 -0
  34. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +2 -2
  35. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +5 -2
  36. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +2 -1
  37. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  38. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  39. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  40. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  41. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  42. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  43. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -36
  44. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +55 -0
  45. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  46. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -1
  47. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  49. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
+ from types import MappingProxyType
7
8
  from pathlib import Path
8
9
  from typing import Any, Callable, Dict, Iterable, List, Optional
9
10
 
@@ -33,6 +34,10 @@ EVENTS_MAX_LIMIT = 200
33
34
  LEVELS = ("info", "notice", "warning", "error")
34
35
 
35
36
 
37
+ # The dashboard kit names its chip `warn`; an event written at that name is stored at the feed's `warning` (FAA-276).
38
+ LEVEL_ALIASES = MappingProxyType({"warn": "warning"})
39
+
40
+
36
41
  _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}\Z")
37
42
 
38
43
 
@@ -45,13 +50,15 @@ _SPAN_ID = re.compile(r"^(?!0{16}\Z)[0-9a-f]{16}\Z")
45
50
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
46
51
  link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
47
52
  span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
48
- """One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
53
+ """One activity event: one sentence a person reads, never a machine id. `level` is one of `LEVELS`; `warn`, the
54
+ dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable
49
55
  fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
50
56
 
51
57
  An event about traced work carries its trace as a pair, `trace_id` and `span_id`
52
58
  (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
59
+ level = LEVEL_ALIASES.get(level, level) if isinstance(level, str) else level
53
60
  if level not in LEVELS:
54
- raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
61
+ raise ServiceError("level must be one of %s (or `warn` for `warning`)." % ", ".join(LEVELS))
55
62
  if not _KIND.match(kind):
56
63
  raise ServiceError("kind %r must be dotted lowercase." % kind)
57
64
  text = " ".join(str(text).split())
@@ -1,6 +1,6 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.2: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
3
+ peer_owner 1.1.0+kit.3: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
4
  DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
5
  commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
6
6
  from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
@@ -18,7 +18,8 @@
18
18
  Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
19
19
  macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
20
20
  `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
21
- `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`).
21
+ `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
22
+ the child's port is waited on with `selectors` and read under one deadline (FAA-273).
22
23
  A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
23
24
  created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
24
25
  The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
@@ -109,7 +110,7 @@ import time
109
110
  from typing import NamedTuple, Optional, Tuple
110
111
 
111
112
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
112
- PEER_OWNER_VERSION = "1.1.0+kit.2" # Observatory's 1.1.0 with the kit's differences above
113
+ PEER_OWNER_VERSION = "1.1.0+kit.3" # Observatory's 1.1.0 with the kit's differences above
113
114
 
114
115
  THIS_ACCOUNT = "this-account"
115
116
  ANOTHER_ACCOUNT = "another-account"
@@ -500,10 +501,34 @@ _CHILD_LISTENER = ("import socket,sys\ns=socket.socket();s.bind(('127.0.0.1',0))
500
501
  "print(s.getsockname()[1],flush=True);sys.stdin.read()")
501
502
 
502
503
 
504
+ def _child_line(child: "subprocess.Popen[bytes]", deadline: float) -> Optional[bytes]:
505
+ """The child's first line, or None when no newline came by `deadline`. It waits with `selectors` (kqueue on
506
+ macOS: no FD_SETSIZE ceiling at fd 1024, which `select.select` refuses) and reads what is there, so a partial line
507
+ from an interpreter that then waits cannot hold the caller past the deadline (FAA-273 item 13, and the `selectors`
508
+ half of item 11)."""
509
+ import selectors # POSIX: this reader runs on macOS alone
510
+ got = b""
511
+ try:
512
+ with selectors.DefaultSelector() as sel:
513
+ sel.register(child.stdout, selectors.EVENT_READ)
514
+ while b"\n" not in got and len(got) < 64:
515
+ left = deadline - time.monotonic()
516
+ if left <= 0 or not sel.select(left):
517
+ return None
518
+ chunk = os.read(child.stdout.fileno(), 64)
519
+ if not chunk:
520
+ return None
521
+ got += chunk
522
+ except (OSError, ValueError):
523
+ return None
524
+ return got.split(b"\n", 1)[0] if b"\n" in got else None
525
+
526
+
503
527
  def _child_listener_seen(rows_of) -> Optional[bool]:
504
528
  """Whether the table shows a listener of a child process of this one: False when it does not (the table shows
505
- this process alone, FAA-267), None when no child could be started or did not answer within TIMEOUT_SECONDS (a
506
- frozen or embedded host has no interpreter to start). The calibration trusts only True."""
529
+ this process alone, FAA-267), None when no child could be started (a frozen or embedded host has no interpreter to
530
+ start), or it gave no port line within TIMEOUT_SECONDS (silence, a partial or non-numeric line, an early exit, a
531
+ pipe that cannot be read). The calibration trusts only True."""
507
532
  if not sys.executable or getattr(sys, "frozen", False):
508
533
  return None
509
534
  try:
@@ -511,21 +536,25 @@ def _child_listener_seen(rows_of) -> Optional[bool]:
511
536
  stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
512
537
  except OSError:
513
538
  return None
539
+ deadline = time.monotonic() + TIMEOUT_SECONDS
540
+ port = None
514
541
  try:
515
- import select # POSIX: this reader runs on macOS alone
516
- if not select.select([child.stdout], [], [], TIMEOUT_SECONDS)[0]:
517
- return None
518
- line = child.stdout.readline().strip()
519
- if not line.isdigit():
542
+ line = _child_line(child, deadline)
543
+ if line is None or not line.strip().isdigit():
520
544
  return None
521
- port = int(line)
522
- return any(r.listening and r.local == ("127.0.0.1", port) and r.pid == child.pid for r in rows_of())
523
- except (TableUnreadable, OSError, struct.error, ValueError):
524
- return False
545
+ port = int(line.strip())
546
+ try:
547
+ return any(r.listening and r.local == ("127.0.0.1", port) and r.pid == child.pid for r in rows_of())
548
+ except (TableUnreadable, OSError, struct.error, ValueError):
549
+ return False
525
550
  finally:
551
+ # One deadline for the whole check: a child that gave no port is killed at once, and one that did gets what is
552
+ # left of the deadline to exit on its closed stdin before it is killed.
526
553
  try:
527
554
  child.stdin.close()
528
- child.wait(timeout=TIMEOUT_SECONDS)
555
+ if port is None:
556
+ child.kill()
557
+ child.wait(timeout=max(0.0, deadline - time.monotonic()))
529
558
  except (OSError, subprocess.TimeoutExpired):
530
559
  child.kill()
531
560
  child.wait()
@@ -275,6 +275,7 @@ from fabric_auth import ( # noqa: F401
275
275
  from fabric_events import ( # noqa: F401
276
276
  EVENTS_DEFAULT_LIMIT,
277
277
  EVENTS_MAX_LIMIT,
278
+ LEVEL_ALIASES,
278
279
  LEVELS,
279
280
  _KIND,
280
281
  _TRACE_ID,
@@ -313,6 +314,8 @@ from fabric_supervisors import ( # noqa: F401
313
314
  launchd_plist,
314
315
  _launchctl,
315
316
  _domain,
317
+ SYSTEM_DAEMONS,
318
+ launchd_domain,
316
319
  launchd_loaded,
317
320
  launchd_pid,
318
321
  fetch_well_known,
@@ -45,6 +45,10 @@ DEFAULT_EXIT_TIMEOUT = 15
45
45
 
46
46
 
47
47
  _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
48
+ _USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
49
+
50
+ #: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
51
+ SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
48
52
 
49
53
 
50
54
  def _no_marker_argument(argv: Sequence[str]) -> None:
@@ -255,7 +259,7 @@ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, syste
255
259
 
256
260
  def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
257
261
  stdout_path: Path, environment: Optional[Dict[str, str]] = None,
258
- exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
262
+ exit_timeout: int = DEFAULT_EXIT_TIMEOUT, user_name: Optional[str] = None) -> bytes:
259
263
  """RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
260
264
 
261
265
  ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
@@ -264,7 +268,10 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
264
268
 
265
269
  The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
266
270
  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."""
271
+ an argument shaped like a credential, and a control character in any string the plist carries.
272
+
273
+ ``user_name`` writes ``UserName``, the account a LaunchDaemon (system domain, FAA-278) runs as; it must be a macOS
274
+ short name, and it is meaningful only for a plist installed in ``/Library/LaunchDaemons``."""
268
275
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
269
276
  if not _LABEL.match(str(label)):
270
277
  raise ServiceError("label must match %s" % _LABEL.pattern)
@@ -273,6 +280,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
273
280
  raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
274
281
  if not exit_timeout > DRAIN_SECONDS:
275
282
  raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
283
+ if user_name is not None and not _USER_NAME.match(str(user_name)):
284
+ raise ServiceError("UserName must be a macOS short name matching %s" % _USER_NAME.pattern)
276
285
  env = dict(environment or {})
277
286
  for key, value in env.items():
278
287
  if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
@@ -303,6 +312,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
303
312
  "StandardErrorPath": str(stdout_path),
304
313
  "EnvironmentVariables": env,
305
314
  }
315
+ if user_name is not None:
316
+ plist["UserName"] = str(user_name)
306
317
  return plistlib.dumps(plist)
307
318
 
308
319
 
@@ -317,16 +328,52 @@ def _domain() -> str:
317
328
  return "gui/%d" % os.getuid()
318
329
 
319
330
 
320
- def launchd_loaded(label: str) -> bool:
321
- return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
331
+ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = None) -> str:
332
+ """The launchd domain a job lives in: ``system`` for a LaunchDaemon (``domain="system"``, or a plist in
333
+ ``/Library/LaunchDaemons``), else this user's ``gui/<uid>`` (FAA-278). A LaunchDaemon starts at boot, before any
334
+ login, so it is the form for a headless Mac that a FileVault restart (``fdesetup authrestart``) leaves with no GUI
335
+ session; a ``gui/<uid>`` job starts only while its user is logged in."""
336
+ if domain not in (None, "gui", "system"):
337
+ raise ServiceError("domain must be gui or system")
338
+ if domain is None and plist_path is not None:
339
+ folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
340
+ domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
341
+ if domain == "system":
342
+ if sys.platform != "darwin":
343
+ raise ServiceError("launchd is the supervisor on macOS only; use systemd_install on Linux and task_install on "
344
+ "Windows.")
345
+ return "system"
346
+ return _domain()
347
+
348
+
349
+ def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
350
+ """A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
351
+ if domain != "system" or os.geteuid() == 0:
352
+ return
353
+ if install:
354
+ raise ServiceError("A LaunchDaemon (launchd's system domain) is installed by root, and this process is not "
355
+ "root: run launchd_install as root (the release, descriptor and token stay its UserName's, "
356
+ "lifecycle reference, The system domain), or as root write %s and run "
357
+ "`launchctl bootstrap system %s`. Status and pid read without root." % (plist_path, plist_path))
358
+ raise ServiceError("A LaunchDaemon (launchd's system domain) is removed by root, and this process is not root: run "
359
+ "launchd_uninstall as root, or as root run `launchctl bootout system/%s`, remove %s and run "
360
+ "`launchctl enable system/%s` (so a later install is not kept off). Status and pid read without "
361
+ "root." % (label, plist_path, label))
362
+
363
+
364
+ def launchd_loaded(label: str, domain: Optional[str] = None) -> bool:
365
+ """Whether launchd has the job ``label`` loaded in this user's ``gui/<uid>`` domain, or with ``domain="system"`` in
366
+ the system domain (FAA-278); reading needs no root."""
367
+ return _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label)).returncode == 0
322
368
 
323
369
 
324
370
  _PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
325
371
 
326
372
 
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))
373
+ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
374
+ """The running pid of this account's launchd job ``label`` (with ``domain="system"``, of the LaunchDaemon), or
375
+ None (not running, or not readable). Reading needs no root."""
376
+ result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
330
377
  if result.returncode != 0:
331
378
  return None
332
379
  for line in (result.stdout or "").splitlines():
@@ -340,7 +387,8 @@ def _read_well_known(origin: str, timeout: float = 2.0,
340
387
  vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
341
388
  """(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
342
389
  connection whose server end is this OS account's process (DEC-0154 HA-2): another account's, root's, or one whose
343
- owner cannot be told is `foreign-user`, never this service's answer. `vouch_pid`, the pid of a launchd job this
390
+ owner cannot be told is never this service's answer, labelled `foreign-user`, or `table-restricted` when the socket
391
+ table hides other processes. `vouch_pid`, the pid of a launchd job this
344
392
  caller started, proves the listener when the socket table hides other processes (FAA-267)."""
345
393
  parts = urllib.parse.urlsplit(origin)
346
394
  if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
@@ -363,6 +411,107 @@ def _read_well_known(origin: str, timeout: float = 2.0,
363
411
  return None, None
364
412
 
365
413
 
414
+ def _job_listens(pid: int, port: int) -> bool:
415
+ """Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
416
+ (`lsof -p`), which a restricted socket table does not hide. Used only for a LaunchDaemon root just bootstrapped,
417
+ whose pid launchd itself named (FAA-278)."""
418
+ if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
419
+ return False
420
+ try:
421
+ out = _po._run([_po.LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
422
+ except _po.TableUnreadable:
423
+ return False
424
+ return any(host == "127.0.0.1" and p == port for host, p in _po.parse_lsof_listeners(out)) # exact bind, as RT-3
425
+
426
+
427
+ def _account_uid(user_name: str) -> Optional[int]:
428
+ """The uid of account ``user_name`` on this machine, or None when it has no such account. Only a short name: macOS
429
+ getpwnam also answers an upper-case name or the full name, which launchd's UserName does not take."""
430
+ if not isinstance(user_name, str) or not _USER_NAME.match(user_name):
431
+ return None
432
+ try:
433
+ import pwd
434
+ return pwd.getpwnam(user_name).pw_uid
435
+ except (ImportError, KeyError, ValueError):
436
+ return None
437
+
438
+
439
+ def _pid_uid(pid: int) -> Optional[int]:
440
+ """The uid process ``pid`` runs as (``ps -o uid= -p``), or None when it is gone or cannot be read."""
441
+ try:
442
+ return _po.parse_ps_uid(_po._run([_po.PS, "-o", "uid=", "-p", str(pid)]))
443
+ except _po.TableUnreadable:
444
+ return None
445
+
446
+
447
+ def _positive(value: Any) -> bool:
448
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
449
+
450
+
451
+ class _JobConnection(http.client.HTTPConnection):
452
+ """A connection whose server end must be launchd's job `job_pid`, running as `job_uid`, its UserName's uid (a
453
+ LaunchDaemon runs as its UserName, so the installer, root, reads it as another account's: the job's own pid and
454
+ uid are the proof, FAA-278; contract DEC-0161, proposed: RT-3 extended to launchd's system domain)."""
455
+
456
+ def __init__(self, host: str, port: int, job_pid: int, timeout: float, job_uid: Optional[int] = None) -> None:
457
+ if not isinstance(job_pid, int) or isinstance(job_pid, bool) or job_pid <= 1:
458
+ raise ServiceError("A LaunchDaemon's answer is read only on its job's pid, a positive number.")
459
+ if not _positive(job_uid):
460
+ raise ServiceError("A LaunchDaemon's answer is read only from its UserName's uid, a number.")
461
+ super().__init__(host, port, timeout=timeout)
462
+ self.job_pid = job_pid
463
+ self.job_uid = job_uid
464
+
465
+ def connect(self) -> None:
466
+ super().connect()
467
+ owner = _po.server_end(self.sock)
468
+ restricted = owner.kind == _po.UNKNOWN and (owner.reason or "").startswith(_po.TABLE_RESTRICTED)
469
+ if owner.pid == self.job_pid and not _job_listens(self.job_pid, self.port):
470
+ self.close()
471
+ # The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
472
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d is not shown listening on exactly "
473
+ "127.0.0.1:%d by lsof (a wildcard is never vouched for); nothing was sent"
474
+ % (self.job_pid, self.port))
475
+ if owner.pid == self.job_pid or (restricted and _job_listens(self.job_pid, self.port)):
476
+ runs_as = _pid_uid(self.job_pid)
477
+ if runs_as == self.job_uid:
478
+ return
479
+ self.close()
480
+ # Not the restricted owner: the pid was proved, only its uid is wrong, and the error says so.
481
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d runs as uid %s, not its UserName's "
482
+ "uid %d; nothing was sent" % (self.job_pid, runs_as, self.job_uid))
483
+ self.close()
484
+ raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the server on port %d is not the LaunchDaemon's pid %d (%s): "
485
+ "another process holds the port, or the job's program starts the server as a child "
486
+ "(start it with exec); nothing was sent"
487
+ % (self.port, self.job_pid, owner.reason or owner.kind), owner)
488
+
489
+
490
+ def _read_job_well_known(origin: str, job_pid: int, job_uid: Optional[int] = None,
491
+ timeout: float = 2.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
492
+ """As `_read_well_known`, for a LaunchDaemon: a loopback origin is read only when launchd's job `job_pid`, running as
493
+ `job_uid`, accepted the connection; (the answer or None, why the listener was refused or None)."""
494
+ try:
495
+ port = urllib.parse.urlsplit(origin).port
496
+ except ValueError: # a port out of range
497
+ port = None
498
+ if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
499
+ raise ServiceError("A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid "
500
+ "prove it; %s is not." % origin)
501
+ conn = _JobConnection("127.0.0.1", port, job_pid, timeout, job_uid)
502
+ try:
503
+ conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % port})
504
+ response = conn.getresponse()
505
+ return (json.loads(response.read().decode()) if response.status == 200 else None), None
506
+ except _po.ListenerRefused as exc:
507
+ restricted = exc.owner is not None and (exc.owner.reason or "").startswith(_po.TABLE_RESTRICTED)
508
+ return None, "%s: %s" % (_po.TABLE_RESTRICTED if restricted else "foreign-user", exc)
509
+ except (OSError, ValueError, http.client.HTTPException):
510
+ return None, None
511
+ finally:
512
+ conn.close()
513
+
514
+
366
515
  def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
367
516
  """The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
368
517
  OS account's process (DEC-0154 HA-2)."""
@@ -372,13 +521,13 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
372
521
  _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
373
522
 
374
523
 
375
- def launchd_override(label: str) -> Optional[str]:
524
+ def launchd_override(label: str, domain: Optional[str] = None) -> Optional[str]:
376
525
  """The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
377
526
  none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
378
527
 
379
- Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
528
+ Reads ``launchctl print-disabled gui/<uid>`` (``system`` with ``domain="system"``); macOS 14+ prints ``=> disabled|enabled``,
380
529
  older releases ``=> true|false`` (true means disabled)."""
381
- result = _launchctl("print-disabled", _domain())
530
+ result = _launchctl("print-disabled", launchd_domain(domain=domain))
382
531
  if result.returncode != 0:
383
532
  return "unknown"
384
533
  for line in result.stdout.splitlines():
@@ -388,9 +537,63 @@ def launchd_override(label: str) -> Optional[str]:
388
537
  return None
389
538
 
390
539
 
540
+ _PATH_ROW = re.compile(r"^\s*path\s*=\s*(.+?)\s*\Z")
541
+
542
+
543
+ def _plist_field(path: Path, key: str) -> Optional[str]:
544
+ try:
545
+ value = plistlib.loads(Path(path).read_bytes()).get(key)
546
+ except Exception: # unreadable or not a plist: it claims nothing
547
+ return None
548
+ return value if isinstance(value, str) else None
549
+
550
+
551
+ def _same_file(a: Any, b: Any) -> bool:
552
+ """One file, whatever the case of its path (APFS ignores case, realpath keeps it)."""
553
+ try:
554
+ return os.path.samefile(str(a), str(b))
555
+ except OSError:
556
+ return os.path.realpath(str(a)) == os.path.realpath(str(b))
557
+
558
+
559
+ def _label_holders(label: str, plist_path: Path) -> List[Path]:
560
+ """Other plists that hold launchd label ``label`` in the system domain (FAA-278): the plist launchd loaded the job
561
+ from, when it is not ``plist_path``, and any plist of that label beside ``plist_path``. Reading needs no root."""
562
+ found: List[Path] = []
563
+ result = _launchctl("print", "system/%s" % label)
564
+ if result.returncode == 0:
565
+ for line in (result.stdout or "").splitlines():
566
+ hit = _PATH_ROW.match(line)
567
+ if hit:
568
+ found.append(Path(hit.group(1)))
569
+ break
570
+ try:
571
+ siblings = sorted(Path(plist_path).parent.glob("*.plist"))
572
+ except OSError:
573
+ siblings = []
574
+ found += [p for p in siblings if _plist_field(p, "Label") == label]
575
+ return [p for p in dict.fromkeys(found) if not _same_file(p, plist_path)]
576
+
577
+
578
+ def _answers(doc: Any, service_id: str, instance: str) -> bool:
579
+ """Whether a well-known answer names this service and instance; any other JSON is no answer."""
580
+ service = doc.get("service") if isinstance(doc, dict) else None
581
+ return isinstance(service, dict) and service.get("id") == service_id and service.get("instance") == instance
582
+
583
+
584
+ def _refuse_held_label(label: str, plist_path: Path) -> None:
585
+ holders = _label_holders(label, plist_path)
586
+ if holders:
587
+ owner = _plist_field(holders[0], "UserName")
588
+ raise ServiceError("Label %s is already held by %s%s: the system domain is shared by every account of this "
589
+ "Mac, so a label is the machine's and one plist holds it. Choose a label of your own (for "
590
+ "example with the account in it), or remove that LaunchDaemon as root first."
591
+ % (label, holders[0], " (%s's)" % owner if owner else ""))
592
+
593
+
391
594
  def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
392
595
  service_id: str, instance: str = "default", timeout: float = 40.0,
393
- force_enable: bool = False) -> Dict[str, Any]:
596
+ force_enable: bool = False, domain: Optional[str] = None) -> Dict[str, Any]:
394
597
  """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
395
598
  poll the well-known document until it answers with this identity.
396
599
 
@@ -398,38 +601,111 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
398
601
  label. A label the operator disabled stays disabled and unloaded — the plist is still
399
602
  rewritten, so the new release is what starts once they turn it back on — and the answer is
400
603
  ``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
401
- ``force_enable`` is for an operator who asked for the service to be switched on."""
604
+ ``force_enable`` is for an operator who asked for the service to be switched on.
605
+
606
+ A plist in ``/Library/LaunchDaemons`` (or ``domain="system"``) is a LaunchDaemon in launchd's system domain, started
607
+ at boot with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the
608
+ answer counts only when launchd's own job accepted the connection (its ``UserName``, not the installer, owns it).
609
+ A ``gui/<uid>`` bootstrap with no GUI session (SSH, a headless Mac after a FileVault restart) says so."""
402
610
  plist_path = Path(plist_path)
611
+ dom = launchd_domain(plist_path, domain)
612
+ which = "system" if dom == "system" else None
613
+ _need_root(dom, label, plist_path, True)
614
+ if dom == "system":
615
+ try:
616
+ port = urllib.parse.urlsplit(origin).port
617
+ except ValueError: # a port out of range
618
+ port = None
619
+ if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
620
+ raise ServiceError("A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by "
621
+ "the job's pid and uid (FAA-278); %s is not." % origin)
622
+ try:
623
+ named = plistlib.loads(plist_bytes).get("UserName")
624
+ except Exception: # a plist that does not parse fails plutil -lint below
625
+ named = None
626
+ named = named if isinstance(named, str) else None # a UserName that is not a string names no account
627
+ if named:
628
+ _refuse_held_label(label, plist_path)
629
+ if named and plist_path.exists():
630
+ installed_label = _plist_field(plist_path, "Label")
631
+ if installed_label is not None and installed_label != label:
632
+ raise ServiceError("%s holds launchd label %s, not %s: writing over it would leave that job loaded from a "
633
+ "plist that no longer describes it. Uninstall it as root first, or write this one at "
634
+ "%s/%s.plist." % (plist_path, installed_label, label, SYSTEM_DAEMONS, label))
635
+ installed = _plist_field(plist_path, "UserName")
636
+ if installed and installed != named:
637
+ raise ServiceError("%s is %s's LaunchDaemon: the system domain is shared by every account of this Mac, so "
638
+ "a label is the machine's. Choose a label of your own (for example with the account in "
639
+ "it), or uninstall that one as root first." % (plist_path, installed))
640
+ if named and not _USER_NAME.match(named):
641
+ raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
642
+ % (named, _USER_NAME.pattern))
643
+ job_uid = _account_uid(named) if named else None
644
+ if named and job_uid is None:
645
+ raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
646
+ "whose Fabric and agents call the service." % named)
647
+ if job_uid == 0:
648
+ raise ServiceError("UserName %s is root: the job would run as root, keep its data in root's home, and every "
649
+ "client refuses root's listener (DEC-0154 HA-2). Name the account whose Fabric and agents "
650
+ "call the service." % named)
651
+ if not named:
652
+ raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
653
+ "home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
654
+ "launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
403
655
  atomic_write(plist_path, plist_bytes, 0o644)
404
656
  lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
405
657
  if lint.returncode != 0:
406
658
  raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
407
- target = "%s/%s" % (_domain(), label)
408
- override = launchd_override(label)
659
+ target = "%s/%s" % (dom, label)
660
+ override = launchd_override(label, which)
409
661
  if force_enable or override is None:
410
662
  _launchctl("enable", target)
411
663
  elif override == "disabled":
412
- return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
664
+ return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label, which)}
413
665
  _launchctl("bootout", target)
414
666
  deadline = time.time() + 15
415
- while launchd_loaded(label) and time.time() < deadline:
667
+ while launchd_loaded(label, which) and time.time() < deadline:
416
668
  time.sleep(0.25)
417
669
  last = ""
418
670
  for _ in range(5):
419
- result = _launchctl("bootstrap", _domain(), str(plist_path))
671
+ result = _launchctl("bootstrap", dom, str(plist_path))
420
672
  if result.returncode == 0:
421
673
  break
422
674
  last = (result.stderr or result.stdout).strip()
423
675
  time.sleep(1.0)
424
676
  else:
677
+ if dom != "system" and re.search(r":\s*125:|Domain does not support|Could not find domain", last):
678
+ raise ServiceError("launchctl bootstrap failed: %s. This user has no GUI login session (an SSH session, or a "
679
+ "headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
680
+ "this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
681
+ "domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
425
682
  raise ServiceError("launchctl bootstrap failed: %s" % last)
426
683
  deadline = time.time() + timeout
684
+ if dom == "system":
685
+ foreign = None
686
+ while time.time() < deadline:
687
+ job = launchd_pid(label, "system")
688
+ doc, why = _read_job_well_known(origin, job, job_uid) if job else (None, None)
689
+ foreign = why or foreign # the last refusal stays the reason while launchd shows no pid
690
+ if _answers(doc, service_id, instance):
691
+ return doc
692
+ time.sleep(0.5)
693
+ loaded = ("; the job stays loaded and its plist starts it again at boot: as root, `launchctl bootout system/%s` "
694
+ "and removing %s (or launchd_uninstall) take it away" % (label, plist_path))
695
+ if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
696
+ raise ServiceError("%s could not be proved this LaunchDaemon's: %s; the job's pid did not show the listener: "
697
+ "start the server with exec (no wrapper child), or read the job's log%s."
698
+ % (origin, foreign, loaded))
699
+ if foreign:
700
+ raise ServiceError("%s is not this LaunchDaemon's: %s%s." % (origin, foreign, loaded))
701
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log%s."
702
+ % (origin, service_id, instance, timeout, loaded))
427
703
  foreign = vouch = None
428
704
  while time.time() < deadline:
429
705
  doc, foreign = _read_well_known(origin, vouch_pid=vouch)
430
706
  if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
431
707
  vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
432
- if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
708
+ if _answers(doc, service_id, instance):
433
709
  return doc
434
710
  time.sleep(0.5)
435
711
  if foreign:
@@ -444,20 +720,31 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
444
720
 
445
721
 
446
722
  def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
447
- service_id: Optional[str] = None, instance: str = "default") -> Dict[str, Any]:
723
+ service_id: Optional[str] = None, instance: str = "default",
724
+ domain: Optional[str] = None) -> Dict[str, Any]:
448
725
  """Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
449
726
  the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
450
727
  override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
451
728
  unless ``purge`` (then ``service_id`` names whose data, logs and cache go, and ``instance`` which
452
729
  instance's: ``instance_dirs``, so one context's purge never reaches another's, FAA-125). A job still
453
- loaded at ``timeout`` raises, leaving the plist and data in place."""
730
+ loaded at ``timeout`` raises, leaving the plist and data in place. A LaunchDaemon (a plist in
731
+ ``/Library/LaunchDaemons``, or ``domain="system"``) is removed only by root, and its data belongs to its UserName,
732
+ so ``purge`` is refused for it: remove the data as that user (FAA-278)."""
454
733
  if purge and not service_id:
455
734
  raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
735
+ dom = launchd_domain(Path(plist_path), domain)
736
+ which = "system" if dom == "system" else None
737
+ _need_root(dom, label, Path(plist_path), False)
738
+ if purge and dom == "system":
739
+ raise ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then "
740
+ "remove the data as that user.")
741
+ if dom == "system":
742
+ _refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
456
743
  dirs = instance_dirs(str(service_id), instance) if purge else {}
457
- target = "%s/%s" % (_domain(), label)
744
+ target = "%s/%s" % (dom, label)
458
745
  _launchctl("bootout", target)
459
746
  deadline = time.monotonic() + timeout
460
- while launchd_loaded(label):
747
+ while launchd_loaded(label, which):
461
748
  if time.monotonic() >= deadline:
462
749
  raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
463
750
  time.sleep(0.1)