@passioncode-ai/fabric-agent-adapter 0.21.0 → 0.23.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 (25) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +38 -6
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +43 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  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 +46 -14
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +5 -2
  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 +167 -36
  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 +270 -49
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -2
  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 +217 -0
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
@@ -87,7 +87,8 @@ _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
93
  MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
93
94
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
@@ -106,7 +107,8 @@ class AlreadyRunning(ServiceError):
106
107
  def __init__(self, holder_pid: Optional[int], lock_path: Path):
107
108
  self.holder_pid = holder_pid
108
109
  self.lock_path = lock_path
109
- who = "process %d" % holder_pid if holder_pid else "another process"
110
+ # FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
111
+ who = "process %d" % holder_pid if holder_pid else "an unknown process"
110
112
  super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
111
113
 
112
114
 
@@ -336,35 +338,118 @@ def _read_pid(path: Path) -> Optional[int]:
336
338
  return None
337
339
 
338
340
 
339
- SUPERVISORS = ("launchd", "systemd", "task-scheduler")
341
+ #: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
342
+ SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
343
+ #: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
344
+ SUPERVISOR_ARG = "--fabric-supervisor"
340
345
 
341
346
 
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:
347
+ def _marker_arguments(argv: Sequence[Any]) -> List[str]:
348
+ """The marker values an argument vector carries after the executable and before ``--``. A bare
349
+ ``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
350
+ and consumes nothing."""
351
+ values: List[str] = []
352
+ index = 1
353
+ while index < len(argv):
354
+ item = argv[index]
355
+ if item == "--":
356
+ break
357
+ if item == SUPERVISOR_ARG:
358
+ following = argv[index + 1] if index + 1 < len(argv) else None
359
+ if isinstance(following, str) and not following.startswith("--"):
360
+ values.append(following)
361
+ index += 1
362
+ else:
363
+ values.append("")
364
+ elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
365
+ values.append(item[len(SUPERVISOR_ARG) + 1:])
366
+ index += 1
367
+ return values
368
+
369
+
370
+ def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
371
+ """How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
372
+ ``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
373
+
374
+ - ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
375
+ - ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
376
+ before it takes the lock (an empty or bare argument beside a variable disagrees with it);
377
+ - ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
378
+ names no manager.
379
+
380
+ A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
381
+ env = os.environ if env is None else env
382
+ argv = sys.argv if argv is None else argv
383
+ variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
384
+ from_env = [variable] if isinstance(variable, str) and variable != "" else []
385
+ from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
386
+ forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
387
+ values: List[str] = []
388
+ for value in from_env + from_args:
389
+ if value not in values:
390
+ values.append(value)
391
+ if len(values) > 1:
392
+ return {"kind": "conflict", "values": values, "forms": forms}
393
+ if not values:
394
+ return {"kind": "manual", "forms": forms}
395
+ if values[0] in SUPERVISORS:
396
+ return {"kind": "supervised", "manager": values[0], "forms": forms}
397
+ return {"kind": "manual", "unreadable": values[0], "forms": forms}
398
+
399
+
400
+ def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
401
+ """How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
402
+ return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
403
+
404
+
405
+ def marker_conflict_message(marker: Dict[str, Any]) -> str:
406
+ """The one sentence a copy whose markers disagree exits with, naming the markers present."""
407
+ shown = ", ".join(repr(v) for v in marker["values"])
408
+ if marker["forms"] == ["environment", "argument"]:
409
+ return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
410
+ return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
411
+
412
+
413
+ def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
414
+ """True when a supervisor that restarts on exit started this process and said so with the marker
415
+ (``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
416
+ return supervisor_marker(env, argv)["kind"] == "supervised"
417
+
418
+
419
+ def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
349
420
  """True when launchd started this process from a plist written by ``launchd_plist``.
350
421
 
351
422
  The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
352
423
  ``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
353
- return os.environ.get(SUPERVISOR_ENV) == "launchd"
424
+ return supervisor_marker(env, argv).get("manager") == "launchd"
425
+
426
+
427
+ def add_supervisor_argument(parser: Any) -> Any:
428
+ """Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
429
+ (DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
430
+ import argparse
431
+ parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
432
+ return parser
354
433
 
355
434
 
356
435
  def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
357
436
  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.
437
+ clock: Callable[[], float] = time.monotonic,
438
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
439
+ """Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
360
440
 
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
441
+ Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
442
+ marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
443
+ exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
363
444
  (0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
364
445
  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)."""
446
+ ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
447
+ marker = supervisor_marker(env, argv)
448
+ if marker["kind"] == "conflict":
449
+ print(marker_conflict_message(marker), file=sys.stderr)
450
+ raise SystemExit(EXIT_ALREADY_RUNNING)
366
451
  if wait_seconds is None:
367
- wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised() else 0.0
452
+ wait_seconds = lock_wait_budget_seconds(marker)
368
453
  deadline = clock() + max(0.0, wait_seconds)
369
454
  delay = LOCK_BACKOFF_FIRST_SECONDS
370
455
  announced = False
@@ -390,19 +475,146 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
390
475
  WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
391
476
 
392
477
 
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)
478
+ def token_file_problem(path: Path, file: Dict[str, Any], user: str) -> Optional[str]:
479
+ """windows_token_acl_problem as one sentence naming the file and the refusal, or None."""
480
+ problem = windows_token_acl_problem(file, user)
481
+ return None if problem is None else "Token file %s refused: %s." % (path, problem["message"])
482
+
483
+
484
+ SYSTEM_SID = "S-1-5-18"
485
+ ADMINISTRATORS_SID = "S-1-5-32-544"
486
+ #: A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each.
487
+ _SID_TEXT = re.compile(r"S-1-[0-9]{1,15}(?:-[0-9]{1,10}){0,15}", re.IGNORECASE | re.ASCII)
488
+
489
+
490
+ def _sid(value: Any) -> Optional[str]:
491
+ """A readable SID in upper case (SIDs compare case-insensitively on their S), or None."""
492
+ if not isinstance(value, str) or not _SID_TEXT.fullmatch(value):
493
+ return None
494
+ return value.upper() if all(int(part) <= 0xFFFFFFFF for part in value.split("-")[3:]) else None
495
+
496
+
497
+ def windows_token_acl_problem(file: Any, current_user_sid: Any) -> Optional[Dict[str, Any]]:
498
+ """service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read:
499
+ ``file`` is ``{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}``, the mask
500
+ the ACE's unsigned 32-bit access mask. The first refusal ``{problem, at, sid?, message}``, or None when the file may
501
+ be read: a regular file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every
502
+ allow ACE with a non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything
503
+ unreadable refused. The contract's ``windowsTokenAclProblem``; a refusal names the SID, never the contents."""
504
+ def refuse(problem: str, at: str, message: str, sid: Optional[str] = None) -> Dict[str, Any]:
505
+ out = {"problem": problem, "at": at, "message": message}
506
+ if sid is not None:
507
+ out["sid"] = sid
508
+ return out
509
+ user = _sid(current_user_sid)
510
+ if user is None:
511
+ return refuse("unreadable", "/currentUserSid", "the current user's SID is unreadable")
512
+ if not isinstance(file, dict):
513
+ return refuse("unreadable", "/file", "the token file's metadata is unreadable")
514
+ for flag in ("isRegularFile", "isReparsePoint", "realPathInProfile"):
515
+ if not isinstance(file.get(flag), bool):
516
+ return refuse("unreadable", "/file/" + flag, flag + " is unreadable")
517
+ if file["isReparsePoint"]:
518
+ return refuse("reparse-point", "/file/isReparsePoint", "the token file is a reparse point (a symlink or a junction)")
519
+ if not file["isRegularFile"]:
520
+ return refuse("not-regular-file", "/file/isRegularFile", "the token file is not a regular file")
521
+ if not file["realPathInProfile"]:
522
+ return refuse("outside-profile", "/file/realPathInProfile", "the token file's real path lies outside the user's profile")
523
+ trusted = {user, SYSTEM_SID, ADMINISTRATORS_SID}
524
+ owner = _sid(file.get("ownerSid"))
525
+ if owner is None:
526
+ return refuse("unreadable", "/file/ownerSid", "the owner SID is unreadable")
527
+ if owner not in trusted:
528
+ return refuse("owner", "/file/ownerSid", "the token file is owned by %s, not by the current user, SYSTEM or Administrators" % owner, owner)
529
+ aces = file.get("aces")
530
+ if not isinstance(aces, list):
531
+ return refuse("unreadable", "/file/aces", "the ACL is unreadable")
532
+ for index, ace in enumerate(aces):
533
+ at = "/file/aces/%d" % index
534
+ if not isinstance(ace, dict):
535
+ return refuse("unreadable", at, "ACE %d is unreadable" % index)
536
+ if not isinstance(ace.get("type"), str):
537
+ return refuse("unreadable", at + "/type", "ACE %d has an unreadable type" % index)
538
+ trustee = _sid(ace.get("sid"))
539
+ if trustee is None:
540
+ return refuse("unreadable", at + "/sid", "ACE %d names an unreadable SID" % index)
541
+ mask = ace.get("mask")
542
+ if (not isinstance(mask, (int, float)) or isinstance(mask, bool) or (isinstance(mask, float) and not mask.is_integer())
543
+ or not 0 <= mask <= 0xFFFFFFFF):
544
+ return refuse("unreadable", at + "/mask", "ACE %d has an unreadable access mask" % index)
545
+ if ace["type"] == "deny":
546
+ continue
547
+ if ace["type"] != "allow":
548
+ return refuse("ace-type", at + "/type", "ACE %d for %s is of a type the rule does not read" % (index, trustee), trustee)
549
+ if mask == 0:
550
+ continue
551
+ if trustee not in trusted:
552
+ 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
553
  return None
404
554
 
405
555
 
556
+ #: 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.
557
+ _SDDL_ACE_TYPES = {"A": "allow", "D": "deny", "OA": "allow-object", "OD": "deny-object", "XA": "allow-callback",
558
+ "XD": "deny-callback", "ZA": "allow-object-callback"}
559
+ #: SDDL access-right aliases (MS-DTYP §2.5.1.1, "ace_rights") and their masks.
560
+ _SDDL_RIGHTS = {"GA": 0x10000000, "GR": 0x80000000, "GW": 0x40000000, "GX": 0x20000000, "RC": 0x20000, "SD": 0x10000,
561
+ "WD": 0x40000, "WO": 0x80000, "RP": 0x10, "WP": 0x20, "CC": 0x1, "DC": 0x2, "LC": 0x4, "SW": 0x8,
562
+ "LO": 0x80, "DT": 0x40, "CR": 0x100, "FA": 0x1F01FF, "FR": 0x120089, "FW": 0x120116, "FX": 0x1200A0,
563
+ "KA": 0xF003F, "KR": 0x20019, "KX": 0x20019, "KW": 0x20006}
564
+
565
+
566
+ def _sddl_ace_strings(dacl: str) -> List[str]:
567
+ """The top-level parenthesized ACE strings of an SDDL DACL; a callback ACE's conditional expression may nest."""
568
+ aces, depth, start = [], 0, 0
569
+ for index, char in enumerate(dacl):
570
+ if char == "(":
571
+ if depth == 0:
572
+ start = index + 1
573
+ depth += 1
574
+ elif char == ")" and depth:
575
+ depth -= 1
576
+ if depth == 0:
577
+ aces.append(dacl[start:index])
578
+ return aces
579
+
580
+
581
+ def _sddl_mask(rights: str) -> Optional[int]:
582
+ """An SDDL rights field as the unsigned 32-bit mask, or None when it cannot be read."""
583
+ if re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}", rights):
584
+ return int(rights, 16)
585
+ if len(rights) % 2:
586
+ return None
587
+ mask = 0
588
+ for i in range(0, len(rights), 2):
589
+ if rights[i:i + 2] not in _SDDL_RIGHTS:
590
+ return None
591
+ mask |= _SDDL_RIGHTS[rights[i:i + 2]]
592
+ return mask
593
+
594
+
595
+ def acl_from_sddl(sddl: str, canonical: Callable[[str], str] = lambda sid: sid) -> Tuple[str, List[Dict[str, Any]]]:
596
+ """(owner SID, the ACEs that apply to the object itself as ``{type, sid, mask}``) from an SDDL owner-and-DACL
597
+ string. ``canonical`` turns an SDDL alias (SY, BA, WD…) into its SID. A missing or NULL DACL grants everyone
598
+ everything and is read as an allow ACE for Everyone (S-1-1-0); an inherit-only ACE (flag IO) applies to children
599
+ only and is not read; an unreadable rights field gives mask None, which the rule refuses."""
600
+ owner = sddl.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in sddl else ""
601
+ dacl = sddl.split("D:", 1)[1] if "D:" in sddl else ""
602
+ owner_sid = canonical(owner) if owner else ""
603
+ if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
604
+ return owner_sid, [{"type": "allow", "sid": "S-1-1-0", "mask": 0x1F01FF}]
605
+ aces = []
606
+ for text in _sddl_ace_strings(dacl):
607
+ fields = text.split(";", 6)
608
+ if len(fields) < 6: # unreadable: the rule refuses it
609
+ aces.append({"type": None, "sid": None, "mask": None})
610
+ continue
611
+ kind, flags, rights, trustee = fields[0], fields[1], fields[2], fields[5]
612
+ if "IO" in [flags[i:i + 2] for i in range(0, len(flags), 2)]:
613
+ continue
614
+ aces.append({"type": _SDDL_ACE_TYPES.get(kind, kind), "sid": canonical(trustee), "mask": _sddl_mask(rights)})
615
+ return owner_sid, aces
616
+
617
+
406
618
  if WINDOWS: # pragma: no cover - exercised by the Windows CI job
407
619
  import ctypes
408
620
  from ctypes import wintypes
@@ -430,7 +642,6 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
430
642
  _kernel32.GetCurrentProcess.restype = wintypes.HANDLE
431
643
  _kernel32.LocalFree.argtypes = [_PVOID]
432
644
  _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
433
- _ACE = re.compile(r"\(([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*)(?:;[^()]*)?\)")
434
645
  _CANONICAL: Dict[str, str] = {}
435
646
 
436
647
  def _win_error(what: str, code: Optional[int] = None) -> OSError:
@@ -492,17 +703,9 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
492
703
  finally:
493
704
  _kernel32.LocalFree(sd)
494
705
 
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
706
+ def windows_acl(path: Path) -> Tuple[str, List[Dict[str, Any]]]:
707
+ """(owner SID, the ACEs on the object itself as ``{type, sid, mask}``), read from its SDDL (acl_from_sddl)."""
708
+ return acl_from_sddl(_sddl(path), _canonical_sid)
506
709
 
507
710
  def _win_protect(path: Path, folder: bool) -> None:
508
711
  """A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
@@ -553,14 +756,16 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
553
756
  return ctypes.get_last_error() != 2
554
757
 
555
758
  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
759
  profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
559
760
  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())
761
+ try:
762
+ inside = os.path.commonpath([profile, real]) == profile
763
+ except ValueError: # another drive
764
+ inside = False
765
+ owner, aces = windows_acl(path)
766
+ file = {"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT),
767
+ "realPathInProfile": inside, "ownerSid": owner, "aces": aces}
768
+ return token_file_problem(path, file, windows_user_sid())
564
769
  else:
565
770
  def _create_first_pipe(name: str) -> Any: # noqa: ARG001
566
771
  raise ServiceError("named pipes exist only on Windows")
@@ -594,9 +799,9 @@ def ensure_token(path: Path) -> str:
594
799
  def read_token(path: Path) -> str:
595
800
  """Refuse a symlink, a file owned by someone else, or one others can read.
596
801
 
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."""
802
+ On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
803
+ windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
804
+ never the contents."""
600
805
  path = Path(path)
601
806
  info = os.lstat(path)
602
807
  if stat.S_ISLNK(info.st_mode):
@@ -724,6 +929,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
724
929
  problems.append("a launchd service declares label and plist")
725
930
  if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
726
931
  problems.append("lifecycle.plist must be a local path")
932
+ # DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
933
+ for field in ("label", "plist"):
934
+ if field in life and manager in ("systemd", "task-scheduler"):
935
+ problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
727
936
  # DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
728
937
  for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
729
938
  if manager == owner and field not in life:
@@ -2171,6 +2380,12 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
2171
2380
 
2172
2381
  # --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
2173
2382
 
2383
+ def _no_marker_argument(argv: Sequence[str]) -> None:
2384
+ """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
2385
+ if _marker_arguments(list(argv)):
2386
+ raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
2387
+
2388
+
2174
2389
  def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
2175
2390
  """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
2176
2391
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
@@ -2209,7 +2424,8 @@ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directo
2209
2424
  raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
2210
2425
  env = dict(environment or {})
2211
2426
  _no_secret_in_supervisor_file(argv, env)
2212
- env.setdefault(SUPERVISOR_ENV, "systemd")
2427
+ _no_marker_argument(argv)
2428
+ env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
2213
2429
  # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
2214
2430
  cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
2215
2431
  "the working directory")
@@ -2291,6 +2507,10 @@ def task_xml(task: str, program_arguments: Sequence[str], *, working_directory:
2291
2507
  (description or task, "the description")]:
2292
2508
  _plain(value, what)
2293
2509
  _no_secret_in_supervisor_file(argv, None)
2510
+ _no_marker_argument(argv)
2511
+ # A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
2512
+ cut = argv.index("--") if "--" in argv else len(argv)
2513
+ argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
2294
2514
  who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
2295
2515
  if not who:
2296
2516
  raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
@@ -2363,6 +2583,7 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
2363
2583
  if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
2364
2584
  raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
2365
2585
  env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
2586
+ _no_marker_argument([str(a) for a in program_arguments])
2366
2587
  env[SUPERVISOR_ENV] = "launchd"
2367
2588
  plist = {
2368
2589
  "Label": label,
@@ -361,11 +361,13 @@ def register(args: argparse.Namespace) -> int:
361
361
  return 0
362
362
 
363
363
 
364
- def main(argv: Optional[list] = None) -> int:
364
+ def build_parser() -> argparse.ArgumentParser:
365
365
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
366
+ fs.add_supervisor_argument(parser) # the marker a supervisor passes (DEC-0057), before or after the command
366
367
  sub = parser.add_subparsers(dest="command", required=True)
367
368
  for name in ("serve", "register"):
368
369
  p = sub.add_parser(name)
370
+ fs.add_supervisor_argument(p)
369
371
  p.add_argument("--port", type=int, required=True)
370
372
  p.add_argument("--data-dir", required=True)
371
373
  p.add_argument("--token-file")
@@ -378,7 +380,11 @@ def main(argv: Optional[list] = None) -> int:
378
380
  p.add_argument("--services-dir")
379
381
  p.add_argument("--label")
380
382
  p.add_argument("--plist")
381
- args = parser.parse_args(argv)
383
+ return parser
384
+
385
+
386
+ def main(argv: Optional[list] = None) -> int:
387
+ args = build_parser().parse_args(argv)
382
388
  return serve(args) if args.command == "serve" else register(args)
383
389
 
384
390
 
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.21.0"
8
+ version: "0.23.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "b291bf09e0a9f9fd3c16cf540832cd021203b7d1"
10
+ contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `b291bf09e0a9f9fd3c16cf540832cd021203b7d1` and
126
+ Pin exactly contract `0.1.0` at commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check