@passioncode-ai/fabric-agent-adapter 0.22.0 → 0.24.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 (23) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +64 -14
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -45
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -62
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +10 -3
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -87,9 +87,14 @@ _TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
87
87
  _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
88
88
  # DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
89
89
  # share (`\\server\…`, `\\?\…`) is never a local path.
90
- _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?!/)|[A-Za-z]:[\\/])")
90
+ # DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
91
+ _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
91
92
  # DEC-0032: the supervisor per system and the field that names its job.
92
- MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
93
+ # DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
94
+ # a person installs and starts; its descriptor says it is installed, never that it runs).
95
+ MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
96
+ #: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
97
+ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
93
98
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
94
99
  _UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
95
100
  _TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
@@ -106,7 +111,8 @@ class AlreadyRunning(ServiceError):
106
111
  def __init__(self, holder_pid: Optional[int], lock_path: Path):
107
112
  self.holder_pid = holder_pid
108
113
  self.lock_path = lock_path
109
- who = "process %d" % holder_pid if holder_pid else "another process"
114
+ # FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
115
+ who = "process %d" % holder_pid if holder_pid else "an unknown process"
110
116
  super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
111
117
 
112
118
 
@@ -336,35 +342,118 @@ def _read_pid(path: Path) -> Optional[int]:
336
342
  return None
337
343
 
338
344
 
339
- SUPERVISORS = ("launchd", "systemd", "task-scheduler")
345
+ #: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
346
+ SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
347
+ #: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
348
+ SUPERVISOR_ARG = "--fabric-supervisor"
340
349
 
341
350
 
342
- def supervised() -> bool:
343
- """True when a supervisor that restarts on exit started this process and said so in
344
- ``FABRIC_SERVICE_SUPERVISOR`` (``launchd_plist`` and ``systemd_unit`` set it)."""
345
- return os.environ.get(SUPERVISOR_ENV) in SUPERVISORS
346
-
347
-
348
- def supervised_by_launchd() -> bool:
351
+ def _marker_arguments(argv: Sequence[Any]) -> List[str]:
352
+ """The marker values an argument vector carries after the executable and before ``--``. A bare
353
+ ``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
354
+ and consumes nothing."""
355
+ values: List[str] = []
356
+ index = 1
357
+ while index < len(argv):
358
+ item = argv[index]
359
+ if item == "--":
360
+ break
361
+ if item == SUPERVISOR_ARG:
362
+ following = argv[index + 1] if index + 1 < len(argv) else None
363
+ if isinstance(following, str) and not following.startswith("--"):
364
+ values.append(following)
365
+ index += 1
366
+ else:
367
+ values.append("")
368
+ elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
369
+ values.append(item[len(SUPERVISOR_ARG) + 1:])
370
+ index += 1
371
+ return values
372
+
373
+
374
+ def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
375
+ """How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
376
+ ``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
377
+
378
+ - ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
379
+ - ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
380
+ before it takes the lock (an empty or bare argument beside a variable disagrees with it);
381
+ - ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
382
+ names no manager.
383
+
384
+ A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
385
+ env = os.environ if env is None else env
386
+ argv = sys.argv if argv is None else argv
387
+ variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
388
+ from_env = [variable] if isinstance(variable, str) and variable != "" else []
389
+ from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
390
+ forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
391
+ values: List[str] = []
392
+ for value in from_env + from_args:
393
+ if value not in values:
394
+ values.append(value)
395
+ if len(values) > 1:
396
+ return {"kind": "conflict", "values": values, "forms": forms}
397
+ if not values:
398
+ return {"kind": "manual", "forms": forms}
399
+ if values[0] in SUPERVISORS:
400
+ return {"kind": "supervised", "manager": values[0], "forms": forms}
401
+ return {"kind": "manual", "unreadable": values[0], "forms": forms}
402
+
403
+
404
+ def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
405
+ """How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
406
+ return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
407
+
408
+
409
+ def marker_conflict_message(marker: Dict[str, Any]) -> str:
410
+ """The one sentence a copy whose markers disagree exits with, naming the markers present."""
411
+ shown = ", ".join(repr(v) for v in marker["values"])
412
+ if marker["forms"] == ["environment", "argument"]:
413
+ return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
414
+ return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
415
+
416
+
417
+ def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
418
+ """True when a supervisor that restarts on exit started this process and said so with the marker
419
+ (``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
420
+ return supervisor_marker(env, argv)["kind"] == "supervised"
421
+
422
+
423
+ def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
349
424
  """True when launchd started this process from a plist written by ``launchd_plist``.
350
425
 
351
426
  The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
352
427
  ``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
353
- return os.environ.get(SUPERVISOR_ENV) == "launchd"
428
+ return supervisor_marker(env, argv).get("manager") == "launchd"
429
+
430
+
431
+ def add_supervisor_argument(parser: Any) -> Any:
432
+ """Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
433
+ (DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
434
+ import argparse
435
+ parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
436
+ return parser
354
437
 
355
438
 
356
439
  def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
357
440
  sleep: Callable[[float], None] = time.sleep,
358
- clock: Callable[[], float] = time.monotonic) -> InstanceLock:
359
- """Take the lock or exit 75 with one sentence naming the holder.
441
+ clock: Callable[[], float] = time.monotonic,
442
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
443
+ """Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
360
444
 
361
- Started by hand, a held lock exits 75 at once. Under launchd (KeepAlive) an immediate exit
362
- is a respawn every ThrottleInterval, forever; so a supervised copy first backs off in-process
445
+ Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
446
+ marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
447
+ exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
363
448
  (0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
364
449
  leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
365
- ``wait_seconds`` overrides the choice (0 = never wait)."""
450
+ ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
451
+ marker = supervisor_marker(env, argv)
452
+ if marker["kind"] == "conflict":
453
+ print(marker_conflict_message(marker), file=sys.stderr)
454
+ raise SystemExit(EXIT_ALREADY_RUNNING)
366
455
  if wait_seconds is None:
367
- wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised() else 0.0
456
+ wait_seconds = lock_wait_budget_seconds(marker)
368
457
  deadline = clock() + max(0.0, wait_seconds)
369
458
  delay = LOCK_BACKOFF_FIRST_SECONDS
370
459
  announced = False
@@ -390,19 +479,146 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
390
479
  WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
391
480
 
392
481
 
393
- def token_acl_problem(path: Path, owner: str, granting: Sequence[str], user: str) -> Optional[str]:
394
- """service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the
395
- current user, SYSTEM or Administrators (DEC-0033: a file an elevated administrator creates is
396
- owned by Administrators), and every SID an allow ACE grants anything to is one of those three.
397
- Deny ACEs are not passed in: they do not change the decision."""
398
- if owner != user and owner not in WINDOWS_TRUSTED:
399
- return "Token file %s is owned by %s, not by this user (%s), SYSTEM or Administrators." % (path, owner or "nobody", user)
400
- for sid in granting:
401
- if sid != user and sid not in WINDOWS_TRUSTED:
402
- return "Token file %s grants access to %s; only this user, SYSTEM and Administrators may hold any." % (path, sid)
482
+ def token_file_problem(path: Path, file: Dict[str, Any], user: str) -> Optional[str]:
483
+ """windows_token_acl_problem as one sentence naming the file and the refusal, or None."""
484
+ problem = windows_token_acl_problem(file, user)
485
+ return None if problem is None else "Token file %s refused: %s." % (path, problem["message"])
486
+
487
+
488
+ SYSTEM_SID = "S-1-5-18"
489
+ ADMINISTRATORS_SID = "S-1-5-32-544"
490
+ #: A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each.
491
+ _SID_TEXT = re.compile(r"S-1-[0-9]{1,15}(?:-[0-9]{1,10}){0,15}", re.IGNORECASE | re.ASCII)
492
+
493
+
494
+ def _sid(value: Any) -> Optional[str]:
495
+ """A readable SID in upper case (SIDs compare case-insensitively on their S), or None."""
496
+ if not isinstance(value, str) or not _SID_TEXT.fullmatch(value):
497
+ return None
498
+ return value.upper() if all(int(part) <= 0xFFFFFFFF for part in value.split("-")[3:]) else None
499
+
500
+
501
+ def windows_token_acl_problem(file: Any, current_user_sid: Any) -> Optional[Dict[str, Any]]:
502
+ """service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read:
503
+ ``file`` is ``{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}``, the mask
504
+ the ACE's unsigned 32-bit access mask. The first refusal ``{problem, at, sid?, message}``, or None when the file may
505
+ be read: a regular file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every
506
+ allow ACE with a non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything
507
+ unreadable refused. The contract's ``windowsTokenAclProblem``; a refusal names the SID, never the contents."""
508
+ def refuse(problem: str, at: str, message: str, sid: Optional[str] = None) -> Dict[str, Any]:
509
+ out = {"problem": problem, "at": at, "message": message}
510
+ if sid is not None:
511
+ out["sid"] = sid
512
+ return out
513
+ user = _sid(current_user_sid)
514
+ if user is None:
515
+ return refuse("unreadable", "/currentUserSid", "the current user's SID is unreadable")
516
+ if not isinstance(file, dict):
517
+ return refuse("unreadable", "/file", "the token file's metadata is unreadable")
518
+ for flag in ("isRegularFile", "isReparsePoint", "realPathInProfile"):
519
+ if not isinstance(file.get(flag), bool):
520
+ return refuse("unreadable", "/file/" + flag, flag + " is unreadable")
521
+ if file["isReparsePoint"]:
522
+ return refuse("reparse-point", "/file/isReparsePoint", "the token file is a reparse point (a symlink or a junction)")
523
+ if not file["isRegularFile"]:
524
+ return refuse("not-regular-file", "/file/isRegularFile", "the token file is not a regular file")
525
+ if not file["realPathInProfile"]:
526
+ return refuse("outside-profile", "/file/realPathInProfile", "the token file's real path lies outside the user's profile")
527
+ trusted = {user, SYSTEM_SID, ADMINISTRATORS_SID}
528
+ owner = _sid(file.get("ownerSid"))
529
+ if owner is None:
530
+ return refuse("unreadable", "/file/ownerSid", "the owner SID is unreadable")
531
+ if owner not in trusted:
532
+ return refuse("owner", "/file/ownerSid", "the token file is owned by %s, not by the current user, SYSTEM or Administrators" % owner, owner)
533
+ aces = file.get("aces")
534
+ if not isinstance(aces, list):
535
+ return refuse("unreadable", "/file/aces", "the ACL is unreadable")
536
+ for index, ace in enumerate(aces):
537
+ at = "/file/aces/%d" % index
538
+ if not isinstance(ace, dict):
539
+ return refuse("unreadable", at, "ACE %d is unreadable" % index)
540
+ if not isinstance(ace.get("type"), str):
541
+ return refuse("unreadable", at + "/type", "ACE %d has an unreadable type" % index)
542
+ trustee = _sid(ace.get("sid"))
543
+ if trustee is None:
544
+ return refuse("unreadable", at + "/sid", "ACE %d names an unreadable SID" % index)
545
+ mask = ace.get("mask")
546
+ if (not isinstance(mask, (int, float)) or isinstance(mask, bool) or (isinstance(mask, float) and not mask.is_integer())
547
+ or not 0 <= mask <= 0xFFFFFFFF):
548
+ return refuse("unreadable", at + "/mask", "ACE %d has an unreadable access mask" % index)
549
+ if ace["type"] == "deny":
550
+ continue
551
+ if ace["type"] != "allow":
552
+ return refuse("ace-type", at + "/type", "ACE %d for %s is of a type the rule does not read" % (index, trustee), trustee)
553
+ if mask == 0:
554
+ continue
555
+ if trustee not in trusted:
556
+ return refuse("ace-sid", at + "/sid", "the token file grants access to %s; only the current user, SYSTEM and Administrators may hold any" % trustee, trustee)
403
557
  return None
404
558
 
405
559
 
560
+ #: SDDL ACE types (MS-DTYP §2.5.1.1) as the rule's ACE type: allow and deny are read, every other type is refused.
561
+ _SDDL_ACE_TYPES = {"A": "allow", "D": "deny", "OA": "allow-object", "OD": "deny-object", "XA": "allow-callback",
562
+ "XD": "deny-callback", "ZA": "allow-object-callback"}
563
+ #: SDDL access-right aliases (MS-DTYP §2.5.1.1, "ace_rights") and their masks.
564
+ _SDDL_RIGHTS = {"GA": 0x10000000, "GR": 0x80000000, "GW": 0x40000000, "GX": 0x20000000, "RC": 0x20000, "SD": 0x10000,
565
+ "WD": 0x40000, "WO": 0x80000, "RP": 0x10, "WP": 0x20, "CC": 0x1, "DC": 0x2, "LC": 0x4, "SW": 0x8,
566
+ "LO": 0x80, "DT": 0x40, "CR": 0x100, "FA": 0x1F01FF, "FR": 0x120089, "FW": 0x120116, "FX": 0x1200A0,
567
+ "KA": 0xF003F, "KR": 0x20019, "KX": 0x20019, "KW": 0x20006}
568
+
569
+
570
+ def _sddl_ace_strings(dacl: str) -> List[str]:
571
+ """The top-level parenthesized ACE strings of an SDDL DACL; a callback ACE's conditional expression may nest."""
572
+ aces, depth, start = [], 0, 0
573
+ for index, char in enumerate(dacl):
574
+ if char == "(":
575
+ if depth == 0:
576
+ start = index + 1
577
+ depth += 1
578
+ elif char == ")" and depth:
579
+ depth -= 1
580
+ if depth == 0:
581
+ aces.append(dacl[start:index])
582
+ return aces
583
+
584
+
585
+ def _sddl_mask(rights: str) -> Optional[int]:
586
+ """An SDDL rights field as the unsigned 32-bit mask, or None when it cannot be read."""
587
+ if re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}", rights):
588
+ return int(rights, 16)
589
+ if len(rights) % 2:
590
+ return None
591
+ mask = 0
592
+ for i in range(0, len(rights), 2):
593
+ if rights[i:i + 2] not in _SDDL_RIGHTS:
594
+ return None
595
+ mask |= _SDDL_RIGHTS[rights[i:i + 2]]
596
+ return mask
597
+
598
+
599
+ def acl_from_sddl(sddl: str, canonical: Callable[[str], str] = lambda sid: sid) -> Tuple[str, List[Dict[str, Any]]]:
600
+ """(owner SID, the ACEs that apply to the object itself as ``{type, sid, mask}``) from an SDDL owner-and-DACL
601
+ string. ``canonical`` turns an SDDL alias (SY, BA, WD…) into its SID. A missing or NULL DACL grants everyone
602
+ everything and is read as an allow ACE for Everyone (S-1-1-0); an inherit-only ACE (flag IO) applies to children
603
+ only and is not read; an unreadable rights field gives mask None, which the rule refuses."""
604
+ owner = sddl.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in sddl else ""
605
+ dacl = sddl.split("D:", 1)[1] if "D:" in sddl else ""
606
+ owner_sid = canonical(owner) if owner else ""
607
+ if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
608
+ return owner_sid, [{"type": "allow", "sid": "S-1-1-0", "mask": 0x1F01FF}]
609
+ aces = []
610
+ for text in _sddl_ace_strings(dacl):
611
+ fields = text.split(";", 6)
612
+ if len(fields) < 6: # unreadable: the rule refuses it
613
+ aces.append({"type": None, "sid": None, "mask": None})
614
+ continue
615
+ kind, flags, rights, trustee = fields[0], fields[1], fields[2], fields[5]
616
+ if "IO" in [flags[i:i + 2] for i in range(0, len(flags), 2)]:
617
+ continue
618
+ aces.append({"type": _SDDL_ACE_TYPES.get(kind, kind), "sid": canonical(trustee), "mask": _sddl_mask(rights)})
619
+ return owner_sid, aces
620
+
621
+
406
622
  if WINDOWS: # pragma: no cover - exercised by the Windows CI job
407
623
  import ctypes
408
624
  from ctypes import wintypes
@@ -430,7 +646,6 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
430
646
  _kernel32.GetCurrentProcess.restype = wintypes.HANDLE
431
647
  _kernel32.LocalFree.argtypes = [_PVOID]
432
648
  _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
433
- _ACE = re.compile(r"\(([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*)(?:;[^()]*)?\)")
434
649
  _CANONICAL: Dict[str, str] = {}
435
650
 
436
651
  def _win_error(what: str, code: Optional[int] = None) -> OSError:
@@ -492,17 +707,9 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
492
707
  finally:
493
708
  _kernel32.LocalFree(sd)
494
709
 
495
- def windows_acl(path: Path) -> Tuple[str, List[str]]:
496
- """(owner SID, SIDs of every allow ACE on the object itself); a missing or NULL list grants
497
- everyone everything, reported as Everyone (S-1-1-0)."""
498
- text = _sddl(path)
499
- owner = text.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in text else ""
500
- dacl = text.split("D:", 1)[1] if "D:" in text else ""
501
- if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
502
- return _canonical_sid(owner) if owner else "", ["S-1-1-0"]
503
- granting = [_canonical_sid(trustee) for kind, flags, _r, _o, _i, trustee in _ACE.findall(dacl)
504
- if kind in ("A", "OA", "XA", "ZA") and "IO" not in flags]
505
- return (_canonical_sid(owner) if owner else ""), granting
710
+ def windows_acl(path: Path) -> Tuple[str, List[Dict[str, Any]]]:
711
+ """(owner SID, the ACEs on the object itself as ``{type, sid, mask}``), read from its SDDL (acl_from_sddl)."""
712
+ return acl_from_sddl(_sddl(path), _canonical_sid)
506
713
 
507
714
  def _win_protect(path: Path, folder: bool) -> None:
508
715
  """A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
@@ -553,14 +760,16 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
553
760
  return ctypes.get_last_error() != 2
554
761
 
555
762
  def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
556
- if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
557
- return "Token file %s is not a regular file (a link or junction); refusing it." % path
558
763
  profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
559
764
  real = os.path.normcase(os.path.realpath(str(path)))
560
- if os.path.commonpath([profile, real]) != profile:
561
- return "Token file %s lies outside the user's profile; refusing it." % path
562
- owner, granting = windows_acl(path)
563
- return token_acl_problem(path, owner, granting, windows_user_sid())
765
+ try:
766
+ inside = os.path.commonpath([profile, real]) == profile
767
+ except ValueError: # another drive
768
+ inside = False
769
+ owner, aces = windows_acl(path)
770
+ file = {"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT),
771
+ "realPathInProfile": inside, "ownerSid": owner, "aces": aces}
772
+ return token_file_problem(path, file, windows_user_sid())
564
773
  else:
565
774
  def _create_first_pipe(name: str) -> Any: # noqa: ARG001
566
775
  raise ServiceError("named pipes exist only on Windows")
@@ -594,9 +803,9 @@ def ensure_token(path: Path) -> str:
594
803
  def read_token(path: Path) -> str:
595
804
  """Refuse a symlink, a file owned by someone else, or one others can read.
596
805
 
597
- On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033): a regular file
598
- whose real path is in the profile, owned by the current user, SYSTEM or Administrators, every
599
- granting ACE naming one of those three; a refusal names the SID, never the contents."""
806
+ On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
807
+ windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
808
+ never the contents."""
600
809
  path = Path(path)
601
810
  info = os.lstat(path)
602
811
  if stat.S_ISLNK(info.st_mode):
@@ -681,7 +890,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
681
890
  remote = placement_of(descriptor) == "remote"
682
891
  if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
683
892
  problems.append("placement must be local or remote")
684
- required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "installedAt", "installedBy")
893
+ life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
894
+ app = life_in.get("manager") == "person"
895
+ # DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
896
+ required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
685
897
  if not remote:
686
898
  required = required + ("paths",)
687
899
  for key in required:
@@ -697,7 +909,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
697
909
  problems.append("instance must match %s" % _INSTANCE.pattern)
698
910
  if remote:
699
911
  problems.extend(remote_origin_problems(descriptor["origin"]))
700
- elif not _ORIGIN.match(str(descriptor["origin"])):
912
+ elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
701
913
  problems.append("origin must be http://127.0.0.1:<port>")
702
914
  auth = descriptor.get("auth") or {}
703
915
  if "tokenFile" not in auth:
@@ -719,11 +931,22 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
719
931
  life = descriptor.get("lifecycle") or {}
720
932
  manager = life.get("manager")
721
933
  if manager not in MANAGERS:
722
- problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
934
+ problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
935
+ # DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
936
+ if manager == "person" and "app" not in life:
937
+ problems.append("a person's app declares lifecycle.app, the installed app")
938
+ if "app" in life and manager != "person":
939
+ problems.append("lifecycle.app belongs to manager person only")
940
+ if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
941
+ problems.append("lifecycle.app must be a local path")
723
942
  if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
724
943
  problems.append("a launchd service declares label and plist")
725
944
  if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
726
945
  problems.append("lifecycle.plist must be a local path")
946
+ # DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
947
+ for field in ("label", "plist"):
948
+ if field in life and manager in _NOT_LAUNCHD:
949
+ problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
727
950
  # DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
728
951
  for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
729
952
  if manager == owner and field not in life:
@@ -740,6 +963,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
740
963
  problems.append("a remote service has no %s %s" % (of, field))
741
964
  if "update" in (descriptor.get("commands") or {}):
742
965
  problems.append("a remote service declares no update command")
966
+ problems.extend(descriptor_member_problems(descriptor))
743
967
  for name, argv in (descriptor.get("commands") or {}).items():
744
968
  if name not in ("doctor", "update"):
745
969
  problems.append("unknown command %s" % name)
@@ -750,6 +974,166 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
750
974
  return problems
751
975
 
752
976
 
977
+ # --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
978
+
979
+ #: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
980
+ PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
981
+ #: The kind of a runner's sign-in, never a credential's value (DEC-0061).
982
+ CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
983
+ #: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
984
+ SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
985
+ _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
986
+ _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
987
+ _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
988
+
989
+
990
+ def _pointer(name: str) -> str:
991
+ """A JSON Pointer token (RFC 6901 §4)."""
992
+ return str(name).replace("~", "~0").replace("/", "~1")
993
+
994
+
995
+ def _text(value: Any, low: int = 1, high: int = 200) -> bool:
996
+ return isinstance(value, str) and low <= len(value) <= high
997
+
998
+
999
+ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
1000
+ """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
1001
+ FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
1002
+ named by its place, never its value)."""
1003
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
1004
+ problems: List[str] = []
1005
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1006
+ by = descriptor.get("installedBy")
1007
+ places: List[Tuple[str, Any]] = []
1008
+ if isinstance(by, str):
1009
+ if not _text(by):
1010
+ problems.append("installedBy is the installer and its version, 1 to 200 characters")
1011
+ places.append(("/installedBy", by))
1012
+ elif isinstance(by, dict):
1013
+ problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
1014
+ if by.get("by") not in ("person", "node", "organization"):
1015
+ problems.append("installedBy.by is person, node or organization")
1016
+ if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
1017
+ problems.append("installedBy.role is operator or administrator for a person")
1018
+ if by.get("by") != "person" and "role" in by:
1019
+ problems.append("installedBy.role belongs to a person only")
1020
+ if not _text(by.get("tool")):
1021
+ problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
1022
+ places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
1023
+ else:
1024
+ problems.append("installedBy is the installer's name or {by, role?, tool}")
1025
+ if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
1026
+ problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
1027
+ runner = descriptor.get("runner")
1028
+ if "runner" in descriptor:
1029
+ if not isinstance(runner, dict):
1030
+ problems.append("runner is {kind?, credentialKind}")
1031
+ runner = {}
1032
+ problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
1033
+ if runner.get("credentialKind") not in CREDENTIAL_KINDS:
1034
+ problems.append("runner.credentialKind is subscription, key, platform-metered or none")
1035
+ if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
1036
+ problems.append("runner.kind is a runner kind's name")
1037
+ if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
1038
+ problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
1039
+ places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
1040
+ if "peer" in descriptor:
1041
+ peer = descriptor.get("peer")
1042
+ if not isinstance(peer, dict):
1043
+ problems.append("peer is {endpoint, custody, residency, maxDataClass}")
1044
+ peer = {}
1045
+ problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
1046
+ endpoint = peer.get("endpoint")
1047
+ match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
1048
+ if not match:
1049
+ problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
1050
+ elif _RESERVED_HOST.search(match.group("host")):
1051
+ problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
1052
+ if peer.get("custody") not in ("organization-managed", "personal"):
1053
+ problems.append("peer.custody is organization-managed or personal")
1054
+ residency = peer.get("residency")
1055
+ if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
1056
+ or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
1057
+ problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
1058
+ if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
1059
+ problems.append("peer.maxDataClass is public, internal, confidential or restricted")
1060
+ if "runner" not in descriptor:
1061
+ problems.append("a service offered to others declares runner (DEC-0061)")
1062
+ elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
1063
+ problems.append("a service offered to others never runs on a subscription (DEC-0061)")
1064
+ for place, value in places:
1065
+ if isinstance(value, str) and fabric_secrets.secret_argument(value):
1066
+ problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
1067
+ return problems
1068
+
1069
+
1070
+ def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
1071
+ """The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
1072
+ `not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
1073
+ request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
1074
+ `peerRefusal`."""
1075
+ if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
1076
+ return "not-offered"
1077
+ ceiling = descriptor["peer"].get("maxDataClass")
1078
+ if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
1079
+ return "peer-invalid"
1080
+ if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
1081
+ return "data-class-unknown"
1082
+ return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
1083
+
1084
+
1085
+ def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
1086
+ """What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
1087
+ translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
1088
+ when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
1089
+ `appDescriptorAction`."""
1090
+ if "/AppTranslocation/" in app_path:
1091
+ return "keep"
1092
+ if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
1093
+ return "write"
1094
+ recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
1095
+ return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
1096
+
1097
+
1098
+ _MISSING_APP = object()
1099
+
1100
+
1101
+ def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
1102
+ """Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
1103
+ A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
1104
+ life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
1105
+ return isinstance(life, dict) and life.get("manager") == "person" and not app_present
1106
+
1107
+
1108
+ def app_present(descriptor: Dict[str, Any]) -> bool:
1109
+ """Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
1110
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1111
+ app = life.get("app")
1112
+ try:
1113
+ return isinstance(app, str) and expand(app).exists()
1114
+ except ServiceError: # not a local path: nothing installed there
1115
+ return False
1116
+
1117
+
1118
+ #: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
1119
+ FABRIC_SERVICE = ("fabric", "default")
1120
+
1121
+
1122
+ def fabric_button_presence(directory: Optional[Path] = None) -> str:
1123
+ """`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
1124
+ descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
1125
+ `data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
1126
+ not read here; a caller that sends anything reads it."""
1127
+ path = descriptor_path(*FABRIC_SERVICE, directory=directory)
1128
+ try:
1129
+ found = json.loads(path.read_text(encoding="utf-8"))
1130
+ except (OSError, ValueError):
1131
+ return "absent"
1132
+ if not isinstance(found, dict) or validate_descriptor(found):
1133
+ return "absent"
1134
+ return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
1135
+
1136
+
753
1137
  def placement_of(descriptor: Dict[str, Any]) -> str:
754
1138
  """DEC-0019: ``local`` unless the descriptor says ``remote``."""
755
1139
  return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
@@ -781,11 +1165,11 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
781
1165
  raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
782
1166
  root = directory or services_dir()
783
1167
  me = "%s.%s" % (descriptor["id"], descriptor["instance"])
784
- # DEC-0019: only a local placement claims a port on this computer.
785
- port = None if placement_of(descriptor) == "remote" else port_of(descriptor["origin"])
1168
+ # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
1169
+ port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
786
1170
  for path, other in read_descriptors(root):
787
1171
  key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
788
- if key == me or port is None: # a remote origin's port is another computer's
1172
+ if key == me or port is None: # a remote origin's port is another computer's; an app claims none
789
1173
  continue
790
1174
  try:
791
1175
  other_port = port_of(str(other.get("origin", "")))
@@ -887,6 +1271,8 @@ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
887
1271
  _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
888
1272
  _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
889
1273
  ROUTES = ("subscription", "key")
1274
+ #: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
1275
+ USAGE_ROUTES = ROUTES + ("platform-metered",)
890
1276
 
891
1277
 
892
1278
  def configuration_problems(doc: Any) -> List[str]:
@@ -1080,12 +1466,12 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1080
1466
  basis = cost_basis or ("unknown" if cost_usd is None else "provider")
1081
1467
  if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
1082
1468
  raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
1083
- if route is not None and route not in ROUTES:
1084
- raise ServiceError("route is subscription or key (DEC-0046).")
1469
+ if route is not None and route not in USAGE_ROUTES:
1470
+ raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1085
1471
  receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1086
1472
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1087
1473
  if route is not None:
1088
- receipt["route"] = route # the operator's own login in its vendor's runner, or an issued key
1474
+ receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1089
1475
  return receipt
1090
1476
 
1091
1477
 
@@ -2137,18 +2523,30 @@ def remote_token_file(service_id: str, instance: str = "default", directory: Opt
2137
2523
 
2138
2524
  def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
2139
2525
  summary: Optional[str] = None, doctor: Optional[List[str]] = None,
2140
- directory: Optional[Path] = None, installed_by: str = "fabric-service register-remote") -> Path:
2526
+ directory: Optional[Path] = None, installed_by: Any = None,
2527
+ runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
2141
2528
  """DEC-0019, installer side: register an online service on THIS computer — the token file
2142
- (0600, never printed) and the descriptor. The same token is set on the platform as a secret."""
2529
+ (0600, never printed) and the descriptor. The same token is set on the platform as a secret.
2530
+
2531
+ DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
2532
+ register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
2533
+ string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
2534
+ credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
2143
2535
  if not token or len(token.strip()) < 16:
2144
2536
  raise ServiceError("the service token must be at least 16 characters.")
2145
2537
  root = directory or services_dir()
2146
2538
  token_file = remote_token_file(service_id, instance, root)
2147
2539
  descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
2148
2540
  "placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
2149
- "lifecycle": {"manager": "none"}, "installedAt": now_iso(), "installedBy": installed_by}
2541
+ "lifecycle": {"manager": "none"}, "installedAt": now_iso(),
2542
+ "installedBy": installed_by if installed_by is not None else
2543
+ {"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
2150
2544
  if summary:
2151
2545
  descriptor["summary"] = summary
2546
+ if runner is not None:
2547
+ descriptor["runner"] = dict(runner)
2548
+ if peer is not None:
2549
+ descriptor["peer"] = dict(peer)
2152
2550
  if doctor:
2153
2551
  descriptor["commands"] = {"doctor": list(doctor)}
2154
2552
  problems = validate_descriptor(descriptor)
@@ -2171,6 +2569,12 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
2171
2569
 
2172
2570
  # --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
2173
2571
 
2572
+ def _no_marker_argument(argv: Sequence[str]) -> None:
2573
+ """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
2574
+ if _marker_arguments(list(argv)):
2575
+ raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
2576
+
2577
+
2174
2578
  def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
2175
2579
  """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
2176
2580
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
@@ -2209,7 +2613,8 @@ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directo
2209
2613
  raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
2210
2614
  env = dict(environment or {})
2211
2615
  _no_secret_in_supervisor_file(argv, env)
2212
- env.setdefault(SUPERVISOR_ENV, "systemd")
2616
+ _no_marker_argument(argv)
2617
+ env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
2213
2618
  # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
2214
2619
  cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
2215
2620
  "the working directory")
@@ -2291,6 +2696,10 @@ def task_xml(task: str, program_arguments: Sequence[str], *, working_directory:
2291
2696
  (description or task, "the description")]:
2292
2697
  _plain(value, what)
2293
2698
  _no_secret_in_supervisor_file(argv, None)
2699
+ _no_marker_argument(argv)
2700
+ # A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
2701
+ cut = argv.index("--") if "--" in argv else len(argv)
2702
+ argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
2294
2703
  who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
2295
2704
  if not who:
2296
2705
  raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
@@ -2363,6 +2772,7 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
2363
2772
  if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
2364
2773
  raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
2365
2774
  env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
2775
+ _no_marker_argument([str(a) for a in program_arguments])
2366
2776
  env[SUPERVISOR_ENV] = "launchd"
2367
2777
  plist = {
2368
2778
  "Label": label,