@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  52. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  54. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  73. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -361,13 +361,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
361
361
  self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
362
362
 
363
363
 
364
+ def _peer_is_loopback(sock: Any) -> bool:
365
+ """Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
366
+ spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
367
+ arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
368
+ try:
369
+ host = str(sock.getpeername()[0]).split("%", 1)[0]
370
+ address = ipaddress.ip_address(host)
371
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
372
+ return False
373
+ mapped = getattr(address, "ipv4_mapped", None)
374
+ return bool((mapped or address).is_loopback or address.is_unspecified)
375
+
376
+
377
+ class _PlainHTTPConnection(http.client.HTTPConnection):
378
+ """Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
379
+ connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
380
+ another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
381
+
382
+ def connect(self) -> None:
383
+ super().connect()
384
+ if not _peer_is_loopback(self.sock):
385
+ return
386
+ owner = _po.server_end(self.sock)
387
+ port = self.sock.getpeername()[1]
388
+ refused = _po.refusal(owner, port, None)
389
+ if refused is not None:
390
+ self.close()
391
+ raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
392
+
393
+
364
394
  def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
365
395
  """`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
366
396
  owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
367
397
  class Http(urllib.request.HTTPHandler):
368
398
  def http_open(self, req):
369
399
  local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
370
- return self.do_open(_watched(_CheckedHTTPConnection if local else http.client.HTTPConnection, watchdog), req)
400
+ return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
371
401
 
372
402
  class Https(urllib.request.HTTPSHandler):
373
403
  def https_open(self, req):
@@ -700,8 +700,76 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
700
700
  return pkg, notes
701
701
 
702
702
 
703
+ #: The agent's source manifest (contract DEC-0149, ``fabric-source/1``): when the folder carries one, ``pack`` writes
704
+ #: ``secrets[]`` from its secret slots and ``state[]`` from its store slots' paths, and FAC-SEM-160 holds them equal.
705
+ SOURCE_FILE = "fabric.source.json"
706
+
707
+
708
+ def _source_manifest(files: Dict[str, bytes]) -> Optional[Dict[str, Any]]:
709
+ """The folder's source manifest, or None when it has none; a refusal when it is no ``fabric-source/1`` object."""
710
+ if SOURCE_FILE not in files:
711
+ return None
712
+ source = _load_json(files[SOURCE_FILE], SOURCE_FILE)
713
+ if source.get("format") != "fabric-source/1":
714
+ raise PackageError("%s is not a fabric-source/1 manifest (its format member)." % SOURCE_FILE)
715
+ return source
716
+
717
+
718
+ def source_members(source: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
719
+ """``secrets[]`` from the secret slots (name, purpose, optional) and ``state[]`` from the store slots (path, purpose)."""
720
+ secrets: List[Dict[str, Any]] = []
721
+ state: List[Dict[str, Any]] = []
722
+ for slot in _objects(source.get("slots")):
723
+ if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
724
+ secrets.append(dict({"name": slot["name"], "purpose": slot.get("purpose")}, **({"optional": True} if slot.get("optional") is True else {})))
725
+ elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
726
+ state.append({"path": slot["path"], "purpose": slot.get("purpose")})
727
+ return secrets, state
728
+
729
+
730
+ def source_package_findings(source: Any, pkg: Any) -> List[Dict[str, str]]:
731
+ """FAC-SEM-160 (contract ``sourcePackageFindings``): the package's secrets[] are the source's secret slots, by name
732
+ and optional; its state[] paths are the store slots' paths when state[] holds any entry."""
733
+ if not isinstance(source, dict) or not isinstance(pkg, dict):
734
+ return []
735
+ out: List[Dict[str, str]] = []
736
+
737
+ def flag(at: str, message: str) -> None:
738
+ out.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
739
+ slots = _objects(source.get("slots"))
740
+ secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
741
+ stores = list(dict.fromkeys(s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)))
742
+ packaged = [(e, i) for i, e in enumerate(pkg.get("secrets") if isinstance(pkg.get("secrets"), list) else []) if isinstance(e, dict)]
743
+ names = {e.get("name") for e, _ in packaged}
744
+ for name in secrets:
745
+ if name not in names:
746
+ flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
747
+ for entry, index in packaged:
748
+ name = entry.get("name")
749
+ if not isinstance(name, str) or name not in secrets:
750
+ flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
751
+ elif secrets[name] != (entry.get("optional") is True):
752
+ flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
753
+ state = pkg.get("state")
754
+ if not isinstance(state, list) or not state:
755
+ return out
756
+ paths = {e.get("path") for e in _objects(state)}
757
+ for path in stores:
758
+ if path not in paths:
759
+ flag("/package/state", "a store slot's path of the source is not in the package's state[]")
760
+ for index, entry in ((i, e) for i, e in enumerate(state) if isinstance(e, dict)):
761
+ if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
762
+ flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
763
+ return out
764
+
765
+
703
766
  def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
704
767
  problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
768
+ if SOURCE_FILE in files:
769
+ try:
770
+ problems += [dict(p, rule=p["code"]) for p in source_package_findings(_source_manifest(files), pkg)]
771
+ except PackageError as exc:
772
+ problems.append({"rule": "source", "instancePath": "/files", "message": str(exc)})
705
773
  problems += [dict(p, rule="path") for p in path_problems(pkg)]
706
774
  problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
707
775
  problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": FOLDER_MESSAGES.get(p["problem"], p["problem"])}
@@ -716,6 +784,24 @@ FOLDER_MESSAGES = {
716
784
  }
717
785
 
718
786
 
787
+ def _secrets_lacking(draft: Dict[str, Any], secrets: List[Dict[str, Any]]) -> List[str]:
788
+ """The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order."""
789
+ have = {s["name"] for s in secrets}
790
+ declared = draft.get("secrets") if isinstance(draft.get("secrets"), list) else []
791
+ lacking: List[str] = []
792
+ for entry in declared:
793
+ name = entry.get("name") if isinstance(entry, dict) else None
794
+ if isinstance(name, str) and name not in have and name not in lacking:
795
+ lacking.append(name)
796
+ return lacking
797
+
798
+
799
+ def _lacking_message(lacking: List[str]) -> str:
800
+ return ("%s declares no secret slot for %s, which %s's secrets[] names: pack writes secrets[] from the manifest's "
801
+ "slots and never drops a declared secret. Add each as a slot of kind secret to %s (or take it out of "
802
+ "secrets[]), then pack again" % (SOURCE_FILE, ", ".join(lacking), PACKAGE_FILE, SOURCE_FILE))
803
+
804
+
719
805
  def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
720
806
  """Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
721
807
  ``apply`` and no problem, ``fabric.package.json`` is rewritten."""
@@ -725,8 +811,24 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
725
811
  raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
726
812
  "resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
727
813
  draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
814
+ source = _source_manifest(files)
815
+ written: List[str] = []
816
+ refused: List[Dict[str, str]] = []
817
+ if source is not None: # DEC-0149: secrets[] and state[] come from the source manifest's slots
818
+ secrets, state = source_members(source)
819
+ lacking = _secrets_lacking(draft, secrets)
820
+ if lacking: # a declared secret is never dropped: the manifest gains its slot first
821
+ refused.append({"rule": "source", "instancePath": "/secrets", "message": _lacking_message(lacking)})
822
+ elif secrets or "secrets" in draft:
823
+ draft["secrets"] = secrets
824
+ written.append("secrets[]")
825
+ if state and draft.get("state") != []: # an empty state[] is a copy packed without state, kept
826
+ draft["state"] = state
827
+ written.append("state[]")
728
828
  pkg, notes = compose(draft, files)
729
- problems = _all_problems(pkg, files)
829
+ if written:
830
+ notes.append("%s written from %s" % (" and ".join(written), SOURCE_FILE))
831
+ problems = refused + _all_problems(pkg, files)
730
832
  suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
731
833
  if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
732
834
  notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
@@ -1,8 +1,11 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.4: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
- DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
- commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
3
+ peer_owner 1.2.0+kit.5: Observatory's peer_owner 1.2.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
+ DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py`, first at
5
+ commit f86f0622 (1.1.0) and read against commit 2180235c (1.2.0, passioncode-ai/project-observatory-dashboard, the
6
+ same licence): the kit's line names 1.2.0 since kit.5 (FAA-258), whose Linux rule kit.1 to kit.4 already carried
7
+ and the contract's CT-254 states (v0.1.0+2026-11-07.3). Observatory's 1.3.x (this account's own sockets through
8
+ libproc, the contract's DEC-0155 RT-2) is not in it yet (FAA-292). It differs
6
9
  from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
7
10
  frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
8
11
  and a service may load a kit file by its path without one), and in writing `Optional[X]`
@@ -11,7 +14,8 @@
11
14
  SID before the system's (a client running as root or SYSTEM owns root's listener), and in
12
15
  two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
13
16
  and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
14
- local address (state 0A; Observatory's 1.2.0 reading), `peer` reads a client
17
+ local address (state 0A; 1.2.0's rule, where the kit names its reasons `not-accepted`, `several-rows` and `closed`
18
+ and Observatory `no-listener`, `listener-no-socket`, `listeners-disagree` and `no-socket`), `peer` reads a client
15
19
  row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
16
20
  a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
17
21
  finds no row or several reads the table once more; ctypes is imported where it is used, so a
@@ -111,7 +115,7 @@ import time
111
115
  from typing import NamedTuple, Optional, Tuple
112
116
 
113
117
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
114
- PEER_OWNER_VERSION = "1.1.0+kit.4" # Observatory's 1.1.0 with the kit's differences above
118
+ PEER_OWNER_VERSION = "1.2.0+kit.5" # Observatory's 1.2.0 with the kit's differences above
115
119
 
116
120
  THIS_ACCOUNT = "this-account"
117
121
  ANOTHER_ACCOUNT = "another-account"
@@ -122,7 +126,8 @@ KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
122
126
 
123
127
  #: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
124
128
  #: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
125
- #: developer machine passes that. 4 MiB holds about 4,800.
129
+ #: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
130
+ #: (0.74.0 product reading 11, FAA-260).
126
131
  MAX_TABLE_BYTES = 4 * 1024 * 1024
127
132
  #: Every subprocess this module starts is stopped after this many seconds.
128
133
  TIMEOUT_SECONDS = 2.0
@@ -804,7 +809,7 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
804
809
  # (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
805
810
  # process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
806
811
  # accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
807
- # pending CT-254, FAA-258).
812
+ # CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
808
813
  # A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
809
814
  # nothing sent to it reaches anyone (0.74.0 unit reading 6).
810
815
  return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
@@ -856,7 +861,7 @@ def server_end(sock: socket.socket) -> Owner:
856
861
  server's and whose remote address is this socket's own, which is the server process's
857
862
  accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
858
863
  On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
859
- answers (the contract's pending CT-254, FAA-258)."""
864
+ answers (the contract's CT-254, HA-2's Linux source)."""
860
865
  try:
861
866
  own, server = sock.getsockname(), sock.getpeername()
862
867
  except OSError as exc:
@@ -20,7 +20,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
20
20
  "fabric-operator-channel": ("0.1",), "fabric-package": ("1",),
21
21
  "fabric-peer-share": ("1",), "fabric-peer-share-envelope": ("1",), "fabric-peer-share-report": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
22
22
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
23
- "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
23
+ "fabric-settings-backup": ("1",), "fabric-source": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
24
24
  "operator-source": ("1",), "org-adaptation": ("1",), "org-adaptation-receipt": ("1",), "org-authority": ("1",), "org-authority-draft": ("1",), "org-key-set": ("1",), "org-key-set-draft": ("1",),
25
25
  "org-membership": ("1",), "org-root-registration": ("1",), "pairing-message": ("1",),
26
26
  "peer-share-deletion": ("1",), "peer-share-frame": ("1",), "peer-share-revocation": ("1",),
@@ -30,8 +30,20 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
30
30
  JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
31
31
  _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
32
32
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
33
- SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
34
- r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
33
+ #: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
34
+ #: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
35
+ #: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
36
+ _KEY_BOUNDARY = (r"(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt\"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}"
37
+ r"|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)")
38
+ #: The provider keys' shapes, prefixes in their canonical case only (DEC-0151 item 7: `SK-` or `GHP_` is no key). An
39
+ #: `sk-` key holds, within 256 characters, a digit and an upper-case letter with 16 or more characters, or a digit with
40
+ #: 32 or more; the look-aheads are bounded to 255 characters and lazy, so a long run of prefixes reads in linear time.
41
+ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
42
+ r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})")
43
+ #: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
44
+ #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
45
+ #: read the matched text or its index.
46
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
35
47
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
36
48
  _BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
37
49
  re.IGNORECASE | re.ASCII)
@@ -194,7 +206,10 @@ def secret_url(value: str) -> bool:
194
206
  _REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
195
207
  _REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
196
208
  _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
197
- _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % SECRET_VALUE.pattern, re.IGNORECASE | re.ASCII)
209
+ #: A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
210
+ #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
211
+ _REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
212
+ _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
198
213
  _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
199
214
 
200
215
 
@@ -228,6 +228,8 @@ from fabric_descriptor import ( # noqa: F401
228
228
  _NOT_LAUNCHD,
229
229
  _AUTH_HEADER,
230
230
  LoopbackHTTPServer,
231
+ PEER_CHECKS,
232
+ connection_refusal,
231
233
  descriptor_path,
232
234
  read_descriptors,
233
235
  validate_descriptor,
@@ -336,6 +338,7 @@ from fabric_supervisors import ( # noqa: F401
336
338
  _launchctl,
337
339
  _domain,
338
340
  SYSTEM_DAEMONS,
341
+ USER_NAME_RULE,
339
342
  launchd_domain,
340
343
  launchd_loaded,
341
344
  launchd_pid,
@@ -47,6 +47,8 @@ DEFAULT_EXIT_TIMEOUT = 15
47
47
 
48
48
  _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
49
49
  _USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
50
+ #: The short-name rule in words, printed alike by both twins (FAA-286 item 13).
51
+ USER_NAME_RULE = "a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -"
50
52
 
51
53
  #: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
52
54
  SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
@@ -373,6 +375,9 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
373
375
  session; a ``gui/<uid>`` job starts only while its user is logged in."""
374
376
  if domain not in (None, "gui", "system"):
375
377
  raise ServiceError("domain must be gui or system")
378
+ if plist_path is not None and re.match(r"^(?:system|gui)(?:/[0-9]*)?\Z", str(plist_path)):
379
+ raise ServiceError("%s is a launchd domain, not a plist path: pass domain=%r (FAA-286)"
380
+ % (plist_path, "system" if str(plist_path) == "system" else "gui"))
376
381
  if domain is None and plist_path is not None:
377
382
  folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
378
383
  domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
@@ -384,6 +389,39 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
384
389
  return _domain()
385
390
 
386
391
 
392
+ def _in_daemons(plist_path: Path) -> bool:
393
+ folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
394
+ return folder.lower() == str(SYSTEM_DAEMONS).lower()
395
+
396
+
397
+ def _pair_problem(plist_path: Path, domain: Optional[str]) -> Optional[str]:
398
+ """A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd
399
+ also loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root).
400
+ Another folder is allowed for a system job, as the tests' and a staging install's are."""
401
+ if domain == "system" and Path(os.path.abspath(str(plist_path))).parent.name.lower() == "launchagents":
402
+ return ("A LaunchDaemon's plist lives in %s: %s is in a LaunchAgents folder, which launchd also loads into "
403
+ "gui/<uid> at login, so the job would run twice." % (SYSTEM_DAEMONS, plist_path))
404
+ if domain == "gui" and _in_daemons(plist_path):
405
+ return ("%s is in %s, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in "
406
+ "~/Library/LaunchAgents." % (plist_path, SYSTEM_DAEMONS))
407
+ return None
408
+
409
+
410
+ def _leftover_agent(label: str, user_name: str) -> Optional[Path]:
411
+ """A LaunchAgent of ``label`` in ``user_name``'s ~/Library/LaunchAgents, which would run a second copy at its login
412
+ (FAA-286 item 6)."""
413
+ try:
414
+ import pwd
415
+ home = Path(pwd.getpwnam(user_name).pw_dir)
416
+ except (ImportError, KeyError, ValueError):
417
+ return None
418
+ try:
419
+ agents = sorted((home / "Library" / "LaunchAgents").glob("*.plist"))
420
+ except OSError:
421
+ return None
422
+ return next((p for p in agents if _plist_field(p, "Label") == label), None)
423
+
424
+
387
425
  def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
388
426
  """A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
389
427
  if domain != "system" or os.geteuid() == 0:
@@ -700,6 +738,9 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
700
738
  read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
701
739
  read_job = fetch_job_well_known or job_well_known_of
702
740
  plist_path = Path(plist_path)
741
+ pair = _pair_problem(plist_path, domain)
742
+ if pair:
743
+ raise ServiceError(pair)
703
744
  dom = launchd_domain(plist_path, domain)
704
745
  which = "system" if dom == "system" else None
705
746
  _need_root(dom, label, plist_path, True)
@@ -713,8 +754,8 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
713
754
  "the job's pid and uid (FAA-278); %s is not." % origin)
714
755
  try:
715
756
  named = plistlib.loads(plist_bytes).get("UserName")
716
- except Exception: # a plist that does not parse fails plutil -lint below
717
- named = None
757
+ except Exception as exc: # named as what it is, never as a missing UserName (FAA-286 item 16)
758
+ raise ServiceError("The plist does not parse (%s): write it with launchd_plist." % type(exc).__name__)
718
759
  named = named if isinstance(named, str) else None # a UserName that is not a string names no account
719
760
  if named:
720
761
  _refuse_held_label(label, plist_path)
@@ -731,7 +772,7 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
731
772
  "it), or uninstall that one as root first." % (plist_path, installed))
732
773
  if named and not _USER_NAME.match(named):
733
774
  raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
734
- % (named, _USER_NAME.pattern))
775
+ % (named, USER_NAME_RULE))
735
776
  job_uid = _account_uid(named) if named else None
736
777
  if named and job_uid is None:
737
778
  raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
@@ -744,6 +785,12 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
744
785
  raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
745
786
  "home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
746
787
  "launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
788
+ leftover = _leftover_agent(label, named)
789
+ if leftover is not None:
790
+ raise ServiceError("%s is a LaunchAgent of label %s: it would start a second copy at %s's login beside this "
791
+ "LaunchDaemon. Remove it first (launchd_uninstall as %s; lifecycle reference, The system "
792
+ "domain)." % (leftover, label, named, named))
793
+ existed = plist_path.exists()
747
794
  atomic_write(plist_path, plist_bytes, 0o644)
748
795
  lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
749
796
  if lint.returncode != 0:
@@ -771,6 +818,16 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
771
818
  "headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
772
819
  "this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
773
820
  "domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
821
+ if dom == "system":
822
+ # a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
823
+ if not existed:
824
+ try:
825
+ plist_path.unlink()
826
+ except OSError:
827
+ pass
828
+ raise ServiceError("launchctl bootstrap failed: %s; %s" % (last, "the plist this call wrote was removed"
829
+ if not plist_path.exists() else "%s stays and loads at the next boot: remove it as root"
830
+ % plist_path))
774
831
  raise ServiceError("launchctl bootstrap failed: %s" % last)
775
832
  deadline = time.time() + timeout
776
833
  if dom == "system":
@@ -834,6 +891,10 @@ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, pu
834
891
  "remove the data as that user.")
835
892
  if dom == "system":
836
893
  _refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
894
+ holds = _plist_field(Path(plist_path), "Label") if Path(plist_path).exists() else None
895
+ if holds is not None and holds != label: # FAA-286 item 9: booting out one job and unlinking another's plist
896
+ raise ServiceError("%s holds launchd label %s, not %s: nothing was removed. Name the plist of %s, or uninstall %s "
897
+ "with its own label." % (plist_path, holds, label, label, holds))
837
898
  dirs = instance_dirs(str(service_id), instance) if purge else {}
838
899
  target = "%s/%s" % (dom, label)
839
900
  _launchctl("bootout", target)