@passioncode-ai/fabric-agent-adapter 0.78.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 (57) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +14 -7
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +6 -1
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +17 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -10
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +215 -25
  46. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -5
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  50. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  52. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -9
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  56. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  57. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -20,7 +20,7 @@ HOLD_RETRY_MAX_SECONDS = 900.0
20
20
  #: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
21
21
  SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
22
22
  "schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
23
- "not-a-hub", "hub-unavailable", "protocol", "not_available"])
23
+ "not-a-hub", "hub-unavailable", "protocol", "not_available", "foreign-user"])
24
24
 
25
25
  _whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
26
26
 
@@ -78,7 +78,7 @@ class SlotKeeper:
78
78
  if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
79
79
  rest = {"project": self.project, "generation": generation}
80
80
  except BoardError as exc:
81
- if exc.code in ("unreachable", "session-ended"):
81
+ if exc.code in ("unreachable", "session-ended", "foreign-user"): # foreign-user: the earlier ask is unknown
82
82
  raise
83
83
  if exc.code == "idempotency_window_expired":
84
84
  rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
45
45
  jcs,
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
+ import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
48
49
  from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
49
50
 
50
51
  REFUSAL_CODES = (
@@ -672,9 +673,21 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
672
673
  return None
673
674
 
674
675
 
675
- def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
676
+ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
677
+ """Plain http, which a key set source allows on loopback only (DEC-0154 HA-2, FAA-255): every connect, a reconnect
678
+ included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's process,
679
+ so keys another account's listener serves are never read; built the way urllib builds a connection."""
680
+
681
+ def __init__(self, host: str, port: Optional[int] = None, timeout: Any = 10.0, **kwargs: Any) -> None:
682
+ http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
683
+ self.expect_pid = None
684
+ self.vouch_pid = None # an issuer's URL is never a process this caller started (FAA-267)
685
+
686
+
687
+ def _opener(deadline: Dict[str, Any], direct: bool = False) -> urllib.request.OpenerDirector:
676
688
  """No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
677
- issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
689
+ issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13). `direct`:
690
+ a loopback source never goes through a proxy from the environment, so the owner check reads the URL's own port."""
678
691
  def watched(base: type) -> type:
679
692
  class Watched(base): # type: ignore[misc, valid-type]
680
693
  def connect(self) -> None:
@@ -695,11 +708,13 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
695
708
 
696
709
  class Http(urllib.request.HTTPHandler):
697
710
  def http_open(self, req: Any) -> Any:
698
- return self.do_open(watched(http.client.HTTPConnection), req)
711
+ return self.do_open(watched(_CheckedHTTPConnection), req) # http is loopback only (the source check)
699
712
 
700
713
  class Https(urllib.request.HTTPSHandler):
701
714
  def https_open(self, req: Any) -> Any:
702
715
  return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
716
+ if direct:
717
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
703
718
  return urllib.request.build_opener(_NoRedirect, Http, Https)
704
719
 
705
720
 
@@ -709,7 +724,8 @@ def _fetch(source: str) -> Any:
709
724
  request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
710
725
  deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
711
726
  try:
712
- with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
727
+ direct = source.lower().startswith("http://")
728
+ with _opener(deadline, direct).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
713
729
  body = b""
714
730
  while len(body) <= 65536 and not deadline["fired"]:
715
731
  chunk = response.read1(65537 - len(body))
@@ -724,6 +740,8 @@ def _fetch(source: str) -> Any:
724
740
  raise
725
741
  except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
726
742
  raise ConnectionError(type(exc).__name__) from None
743
+ except _po.ListenerRefused as exc: # FAA-255: another account's listener, or one that cannot be told
744
+ raise ConnectionError("foreign-user: %s" % exc) from None
727
745
  finally:
728
746
  if deadline["timer"] is not None:
729
747
  deadline["timer"].cancel()
@@ -18,6 +18,7 @@ from pathlib import Path as _Path # noqa: E402
18
18
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
19
19
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
20
20
  from fabric_base import (
21
+ PRIMARY_CAPABILITY,
21
22
  PROTOCOL,
22
23
  ServiceError,
23
24
  _ID,
@@ -49,7 +50,7 @@ from fabric_usage import (
49
50
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
50
51
 
51
52
 
52
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
53
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
53
54
 
54
55
 
55
56
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -628,6 +629,11 @@ def configuration_problems(doc: Any) -> List[str]:
628
629
  return problems
629
630
 
630
631
 
632
+ #: FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
633
+ _RESERVED = ("%%s: %s is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never "
634
+ "serves it as a tool or declares it." % PRIMARY_CAPABILITY)
635
+
636
+
631
637
  def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
632
638
  started_at: str, status: str, degraded: Sequence[Dict[str, str]],
633
639
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
@@ -664,6 +670,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
664
670
  problems = a2a_surface_problems(surfaces["a2a"])
665
671
  if problems:
666
672
  raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
673
+ mcp_surface = surfaces.get("mcp")
674
+ listed = mcp_surface.get("capabilities") if isinstance(mcp_surface, dict) else None
675
+ if isinstance(listed, list) and PRIMARY_CAPABILITY in listed:
676
+ raise ServiceError(_RESERVED % "surfaces.mcp.capabilities")
667
677
  doc: Dict[str, Any] = {
668
678
  "protocol": PROTOCOL,
669
679
  "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
@@ -698,6 +708,9 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
698
708
  "surfaces": surfaces})
699
709
  if findings:
700
710
  raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
711
+ if any(isinstance(a, dict) and a.get("tool") == PRIMARY_CAPABILITY
712
+ for a in extensions[ACTIONS_EXTENSION_KEY].get("actions") or []):
713
+ raise ServiceError(_RESERVED % "an action's tool")
701
714
  doc["extensions"] = dict(extensions)
702
715
  if configuration is not None:
703
716
  problems = configuration_problems(configuration)
@@ -73,8 +73,42 @@ class InstanceLock:
73
73
  #: a file is allowed there).
74
74
  WINDOWS_LOCK_OFFSET = 1 << 30
75
75
 
76
+ #: How many times acquire re-opens service.lock when the file it locked is no longer the file at its path (a
77
+ #: folder swapped under it by a data import), before it gives up with an error naming the path (FAA-293).
78
+ INODE_RETRIES = 3
79
+
76
80
  def acquire(self) -> "InstanceLock":
77
81
  ensure_private_dir(self.path.parent)
82
+ for _ in range(self.INODE_RETRIES):
83
+ if self._acquire_once():
84
+ return self
85
+ raise ServiceError("%s kept moving while it was locked (a folder swapped under the data folder?): no copy "
86
+ "holds a file that is no longer at its path; start again once the folder is settled." % self.path)
87
+
88
+ def _locked_file_is_at_path(self, fd: int) -> bool:
89
+ """The inode this copy locked is the file at the path now (FAA-293). Windows does not rename a folder holding
90
+ an open file, and its lock is a byte range on this handle, so the check is POSIX only."""
91
+ if WINDOWS:
92
+ return True
93
+ try:
94
+ here, held = os.stat(str(self.path)), os.fstat(fd)
95
+ except FileNotFoundError:
96
+ return False
97
+ return (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino)
98
+
99
+ def _let_go(self, fd: int) -> None:
100
+ try:
101
+ os.close(fd)
102
+ except OSError:
103
+ pass
104
+ name, self._name = self._name, None
105
+ if name is not None:
106
+ try:
107
+ _drop_lock_name(name)
108
+ except OSError:
109
+ pass
110
+
111
+ def _acquire_once(self) -> bool:
78
112
  fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
79
113
  try:
80
114
  if WINDOWS:
@@ -92,6 +126,9 @@ class InstanceLock:
92
126
  if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
93
127
  raise AlreadyRunning(_read_pid(self.path), self.path) from None
94
128
  raise
129
+ if not self._locked_file_is_at_path(fd): # another copy may hold the file at the path: re-open that one
130
+ os.close(fd)
131
+ return False
95
132
  try:
96
133
  self._name = _hold_lock_name(self.path)
97
134
  except BaseException:
@@ -114,8 +151,11 @@ class InstanceLock:
114
151
  except OSError: # as with the close: nothing to add to the first error
115
152
  pass
116
153
  raise
154
+ if not self._locked_file_is_at_path(fd): # swapped while the pid was written: this copy holds nothing real
155
+ self._let_go(fd)
156
+ return False
117
157
  self._fd = fd
118
- return self
158
+ return True
119
159
 
120
160
  def release(self) -> None:
121
161
  if self._fd is not None:
@@ -366,6 +366,10 @@ class Client:
366
366
  if answer is not None:
367
367
  answer["attempts"] = attempts
368
368
  return answer
369
+ if outcome == "foreign-user": # FAA-257: the fallback would go to the same refused listener
370
+ raise OpenRouterError("The base URL's port is held by another OS account's process, or its owner "
371
+ "cannot be told; nothing was sent (DEC-0154 HA-2)", kind="foreign-user",
372
+ attempts=attempts)
369
373
  if outcome in _STOP_AT_ONCE.values():
370
374
  raise OpenRouterError("OpenRouter refused the key or the request (%s)" % status, kind=outcome,
371
375
  attempts=attempts)
@@ -390,6 +394,8 @@ class Client:
390
394
  # the provider may have billed a call whose answer never arrived whole (a cut connection looks the
391
395
  # same as a refused one from here): counted, never as $0; over-counting is the safe side
392
396
  self.ledger.record(self._unmeasured(asked, task_run_id, worst))
397
+ if err.kind == "foreign-user": # nothing was sent: nothing billed, and no other model there (FAA-257)
398
+ return "foreign-user", err.status, None, None
393
399
  return ("retry" if err.kind in ("connection", "timeout") else "refused"), err.status, None, None
394
400
  status = response.status
395
401
  if status in _STOP_AT_ONCE:
@@ -22,6 +22,7 @@ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa:
22
22
  from fabric_base import ( # noqa: F401
23
23
  PROTOCOL,
24
24
  EXTENSION_KEY,
25
+ PRIMARY_CAPABILITY,
25
26
  SUPERVISOR_ENV,
26
27
  DRAIN_SECONDS,
27
28
  _ID,
@@ -319,6 +320,9 @@ from fabric_supervisors import ( # noqa: F401
319
320
  launchd_loaded,
320
321
  launchd_pid,
321
322
  fetch_well_known,
323
+ well_known_of,
324
+ job_well_known_of,
325
+ ForeignUserAnswer,
322
326
  _OVERRIDE_ROW,
323
327
  launchd_override,
324
328
  launchd_install,
@@ -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
@@ -383,6 +384,29 @@ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
383
384
  return None
384
385
 
385
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
+
386
410
  def _read_well_known(origin: str, timeout: float = 2.0,
387
411
  vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
388
412
  """(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
@@ -391,10 +415,16 @@ def _read_well_known(origin: str, timeout: float = 2.0,
391
415
  table hides other processes. `vouch_pid`, the pid of a launchd job this
392
416
  caller started, proves the listener when the socket table hides other processes (FAA-267)."""
393
417
  parts = urllib.parse.urlsplit(origin)
394
- if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
395
- 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)
396
426
  try:
397
- 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]})
398
428
  response = conn.getresponse()
399
429
  return (json.loads(response.read().decode()) if response.status == 200 else None), None
400
430
  except _po.ListenerRefused as exc:
@@ -514,10 +544,24 @@ def _read_job_well_known(origin: str, job_pid: int, job_uid: Optional[int] = Non
514
544
 
515
545
  def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
516
546
  """The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
517
- 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."""
518
548
  return _read_well_known(origin, timeout)[0]
519
549
 
520
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
+
521
565
  _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
522
566
 
523
567
 
@@ -593,7 +637,9 @@ def _refuse_held_label(label: str, plist_path: Path) -> None:
593
637
 
594
638
  def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
595
639
  service_id: str, instance: str = "default", timeout: float = 40.0,
596
- force_enable: bool = False, domain: Optional[str] = None) -> 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]:
597
643
  """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
598
644
  poll the well-known document until it answers with this identity.
599
645
 
@@ -606,7 +652,13 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
606
652
  A plist in ``/Library/LaunchDaemons`` (or ``domain="system"``) is a LaunchDaemon in launchd's system domain, started
607
653
  at boot with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the
608
654
  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."""
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
610
662
  plist_path = Path(plist_path)
611
663
  dom = launchd_domain(plist_path, domain)
612
664
  which = "system" if dom == "system" else None
@@ -685,9 +737,10 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
685
737
  foreign = None
686
738
  while time.time() < deadline:
687
739
  job = launchd_pid(label, "system")
688
- doc, why = _read_job_well_known(origin, job, job_uid) if job else (None, None)
740
+ doc = read_job(origin, job, job_uid) if job else None
741
+ why = doc.reason if isinstance(doc, ForeignUserAnswer) else None
689
742
  foreign = why or foreign # the last refusal stays the reason while launchd shows no pid
690
- if _answers(doc, service_id, instance):
743
+ if not why and _answers(doc, service_id, instance):
691
744
  return doc
692
745
  time.sleep(0.5)
693
746
  loaded = ("; the job stays loaded and its plist starts it again at boot: as root, `launchctl bootout system/%s` "
@@ -702,10 +755,11 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
702
755
  % (origin, service_id, instance, timeout, loaded))
703
756
  foreign = vouch = None
704
757
  while time.time() < deadline:
705
- 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
706
760
  if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
707
761
  vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
708
- if _answers(doc, service_id, instance):
762
+ if not foreign and _answers(doc, service_id, instance):
709
763
  return doc
710
764
  time.sleep(0.5)
711
765
  if foreign: