@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.
- package/CHANGELOG.md +98 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -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 +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- 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 +64 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -4
- 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 +317 -45
- 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 +472 -62
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +10 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
+
# 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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
"""
|
|
344
|
-
``
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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 =
|
|
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
|
|
394
|
-
"""
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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):
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
1084
|
-
raise ServiceError("route is subscription or
|
|
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,
|
|
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:
|
|
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(),
|
|
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
|
-
|
|
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,
|