@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.
- package/CHANGELOG.md +112 -0
- package/README.md +38 -6
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/hooks/hooks.json +43 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +46 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +167 -36
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +270 -49
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +217 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
"""
|
|
344
|
-
``
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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 =
|
|
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
|
|
394
|
-
"""
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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,
|
|
497
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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):
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -361,11 +361,13 @@ def register(args: argparse.Namespace) -> int:
|
|
|
361
361
|
return 0
|
|
362
362
|
|
|
363
363
|
|
|
364
|
-
def
|
|
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
|
-
|
|
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.
|
|
8
|
+
version: "0.23.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
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 `
|
|
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
|