@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -50,9 +50,9 @@ def _objects(value: Any) -> List[Any]:
50
50
  return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
51
51
 
52
52
 
53
- def _indexed(value: Any) -> List[Any]:
54
- """The object items of a list with their index as written, so an instancePath names the item after a non-object."""
55
- return [(item, index) for index, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
53
+ from fabric_jsvalue import indexed_objects as _indexed # noqa: E402 - the core's (FAA-65)
54
+ import fabric_time as _ftime # noqa: E402 - the core (FAA-65)
55
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
56
56
 
57
57
 
58
58
  def is_share_grant_id(value: Any) -> bool:
@@ -91,8 +91,8 @@ def grant_findings(grant: Any, at: str = "") -> List[Dict[str, str]]:
91
91
  def _agents_under(record: Dict[str, Any], context: Any, grant_id: Any) -> set:
92
92
  """The agentRefs the placements of ``record`` in ``context`` run under the grant ``grant_id`` (by normal form)."""
93
93
  placements = [placement for machine in _objects(record.get("machines")) for placement in _objects(machine.get("packages"))]
94
- return {_estate._show(placement.get("agentRef", _estate._MISSING)) for placement in placements
95
- if _estate._same(placement.get("context", _estate._MISSING), context)
94
+ return {_jsv.show(placement.get("agentRef", _jsv.MISSING)) for placement in placements
95
+ if _jsv.same(placement.get("context", _jsv.MISSING), context)
96
96
  and isinstance(grant_id, str) and _ids.same_identifier(placement.get("grant"), grant_id)}
97
97
 
98
98
 
@@ -116,7 +116,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
116
116
  if parent is None:
117
117
  continue
118
118
  before = next((old for old in _objects(parent.get("shareGrants"))
119
- if _estate._same(old.get("context", _estate._MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
119
+ if _jsv.same(old.get("context", _jsv.MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
120
120
  and isinstance(grant_id, str) and _ids.same_identifier(old["meta"].get("id"), grant_id)), None)
121
121
  earlier = _agents_under(parent, before.get("context"), grant_id) if before is not None else set()
122
122
  if earlier and any(ref not in earlier for ref in refs):
@@ -126,7 +126,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
126
126
 
127
127
  def _time(value: Any) -> float:
128
128
  """An instant as milliseconds, or NaN: the schema's pattern and a day that exists."""
129
- return _estate.estate_time(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
129
+ return _ftime.parse_rfc3339_ms(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
130
130
 
131
131
 
132
132
  def registration_findings(value: Any) -> List[Dict[str, str]]:
@@ -152,7 +152,7 @@ def grant_usage_context_findings(value: Any) -> List[Dict[str, str]]:
152
152
  for position, event in enumerate(_objects(batch.get("events"))): # the index among the objects, as the contract counts it
153
153
  if event.get("kind") != "grant.usage":
154
154
  continue
155
- context = event["data"].get("context", _estate._MISSING) if isinstance(event.get("data"), dict) else _estate._MISSING
155
+ context = event["data"].get("context", _jsv.MISSING) if isinstance(event.get("data"), dict) else _jsv.MISSING
156
156
  if not isinstance(org, str) or not isinstance(context, str) or context != "org:" + org:
157
157
  findings.append(_finding("FAC-SEM-045", "/batch/events/%d/data/context" % position, "a grant's usage carries its grant's own context, org: and the client certificate's org.id; another organization's is refused as attribution"))
158
158
  return findings
@@ -0,0 +1,412 @@
1
+ """Supervisors (DEC-0032, LC-14): launchd plists, systemd user units and Task Scheduler tasks, installed and
2
+ removed without touching what the operator turned off; release pruning (LC-15). Part of the service kit
3
+ (FAA-65, layer L3)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import plistlib
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import urllib.request
16
+ from pathlib import Path, PurePath
17
+ from typing import Any, Callable, Dict, List, Optional, Sequence
18
+
19
+ import sys as _sys # noqa: E402
20
+ from pathlib import Path as _Path # noqa: E402
21
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
22
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
23
+ from fabric_base import (
24
+ DRAIN_SECONDS,
25
+ SUPERVISOR_ENV,
26
+ ServiceError,
27
+ _TASK,
28
+ _UNIT,
29
+ )
30
+ from fabric_paths import (
31
+ atomic_write,
32
+ service_dirs,
33
+ )
34
+ from fabric_lock import (
35
+ SUPERVISOR_ARG,
36
+ _marker_arguments,
37
+ )
38
+
39
+
40
+ DEFAULT_EXIT_TIMEOUT = 15
41
+
42
+
43
+ def _no_marker_argument(argv: Sequence[str]) -> None:
44
+ """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
45
+ if _marker_arguments(list(argv)):
46
+ raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
47
+
48
+
49
+ def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
50
+ """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
51
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
52
+ for arg in argv:
53
+ if fabric_secrets.secret_argument(arg):
54
+ raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
55
+ for name, value in (environment or {}).items():
56
+ if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
57
+ raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
58
+
59
+
60
+ def _plain(value: str, what: str) -> str:
61
+ """A supervisor file is line- and XML-structured: no control character in anything written into it."""
62
+ if re.search(r"[\x00-\x1f\x7f]", value):
63
+ raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
64
+ return value
65
+
66
+
67
+ def _systemd_quote(value: str) -> str:
68
+ _plain(value, "an argument")
69
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
70
+ return '"%s"' % escaped
71
+
72
+
73
+ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
74
+ environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
75
+ stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
76
+ """A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
77
+ WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
78
+ if not _UNIT.match(unit):
79
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
80
+ argv = [str(a) for a in program_arguments]
81
+ if not argv or not argv[0].startswith("/"):
82
+ raise ServiceError("a systemd unit starts an absolute executable")
83
+ if stop_timeout <= DRAIN_SECONDS:
84
+ raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
85
+ env = dict(environment or {})
86
+ _no_secret_in_supervisor_file(argv, env)
87
+ _no_marker_argument(argv)
88
+ env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
89
+ # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
90
+ cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
91
+ "the working directory")
92
+ if not cwd.startswith("/"):
93
+ raise ServiceError("a systemd unit's working directory is absolute")
94
+ lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
95
+ "[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
96
+ # Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
97
+ "WorkingDirectory=%s" % cwd.replace("%", "%%")]
98
+ for name in env:
99
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
100
+ raise ServiceError("environment name %r is not a variable name" % name)
101
+ lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
102
+ lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
103
+ "[Install]", "WantedBy=default.target", ""]
104
+ return "\n".join(lines)
105
+
106
+
107
+ def _systemd_dir() -> Path:
108
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
109
+
110
+
111
+ def _check(result: Any, what: str) -> None:
112
+ if getattr(result, "returncode", 1) != 0:
113
+ raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
114
+
115
+
116
+ def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
117
+ system: str = sys.platform, force_enable: bool = False) -> Path:
118
+ """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
119
+
120
+ The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
121
+ the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
122
+ if not system.startswith("linux"):
123
+ raise ServiceError("systemd is the supervisor on Linux only")
124
+ if not _UNIT.match(unit):
125
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
126
+ folder = unit_dir or _systemd_dir()
127
+ folder.mkdir(parents=True, exist_ok=True)
128
+ path = folder / unit
129
+ first = not path.exists()
130
+ state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
131
+ "stdout", "") or "").strip()
132
+ atomic_write(path, text.encode("utf-8"), 0o644)
133
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
134
+ if state == "disabled" and not force_enable:
135
+ return path
136
+ _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
137
+ _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
138
+ return path
139
+
140
+
141
+ def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
142
+ system: str = sys.platform) -> None:
143
+ """Stop it and keep it off across logins (`disable --now`), then remove the unit."""
144
+ if not system.startswith("linux"):
145
+ raise ServiceError("systemd is the supervisor on Linux only")
146
+ run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
147
+ path = (unit_dir or _systemd_dir()) / unit
148
+ if path.exists():
149
+ path.unlink()
150
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
151
+
152
+
153
+ def _xml(text: str) -> str:
154
+ return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
155
+
156
+
157
+ def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
158
+ user: Optional[str] = None) -> str:
159
+ """A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
160
+ run only when the user is logged on, created without administrator rights."""
161
+ if not _TASK.match(task):
162
+ raise ServiceError("task must match %s" % _TASK.pattern)
163
+ argv = [str(a) for a in program_arguments]
164
+ if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
165
+ raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
166
+ for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
167
+ (description or task, "the description")]:
168
+ _plain(value, what)
169
+ _no_secret_in_supervisor_file(argv, None)
170
+ _no_marker_argument(argv)
171
+ # A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
172
+ cut = argv.index("--") if "--" in argv else len(argv)
173
+ argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
174
+ who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
175
+ if not who:
176
+ raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
177
+ _plain(who, "the user")
178
+ body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
179
+ <RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
180
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
181
+ <Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
182
+ <Settings>
183
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
184
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
185
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
186
+ <StartWhenAvailable>true</StartWhenAvailable>
187
+ <Enabled>true</Enabled>
188
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
189
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
190
+ </Settings>
191
+ <Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
192
+ </Task>
193
+ """.format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
194
+ arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
195
+ return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
196
+
197
+
198
+ _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
199
+
200
+
201
+ def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
202
+ force_enable: bool = False) -> None:
203
+ """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
204
+ definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
205
+ if system != "win32":
206
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
207
+ query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
208
+ existed = getattr(query, "returncode", 1) == 0
209
+ keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
210
+ fd, name = tempfile.mkstemp(suffix=".xml")
211
+ try:
212
+ with os.fdopen(fd, "wb") as handle:
213
+ handle.write(xml_text.encode("utf-16"))
214
+ _check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
215
+ finally:
216
+ os.unlink(name)
217
+ if keep_off:
218
+ _check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
219
+ return
220
+ if existed:
221
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
222
+ _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
223
+
224
+
225
+ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
226
+ """End the running task, then delete it."""
227
+ if system != "win32":
228
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
229
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
230
+ _check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
231
+
232
+
233
+ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
234
+ stdout_path: Path, environment: Optional[Dict[str, str]] = None,
235
+ exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
236
+ """RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
237
+
238
+ ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
239
+ SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
240
+ instead of exiting into a respawn loop."""
241
+ env = dict(environment or {})
242
+ for key in env:
243
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
244
+ raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
245
+ env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
246
+ _no_marker_argument([str(a) for a in program_arguments])
247
+ env[SUPERVISOR_ENV] = "launchd"
248
+ plist = {
249
+ "Label": label,
250
+ "ProgramArguments": list(program_arguments),
251
+ "WorkingDirectory": str(working_directory),
252
+ "RunAtLoad": True,
253
+ "KeepAlive": True,
254
+ "ThrottleInterval": 10,
255
+ "ExitTimeOut": exit_timeout,
256
+ # Standard, never Background: a host probes the service and agents call it while the Mac
257
+ # is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
258
+ # under load, and every host then reports an outage that never happened.
259
+ "ProcessType": "Standard",
260
+ "StandardOutPath": str(stdout_path),
261
+ "StandardErrorPath": str(stdout_path),
262
+ "EnvironmentVariables": env,
263
+ }
264
+ return plistlib.dumps(plist)
265
+
266
+
267
+ def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
268
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
269
+
270
+
271
+ def _domain() -> str:
272
+ if sys.platform == "win32":
273
+ # DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
274
+ raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
275
+ return "gui/%d" % os.getuid()
276
+
277
+
278
+ def launchd_loaded(label: str) -> bool:
279
+ return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
280
+
281
+
282
+ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
283
+ try:
284
+ with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
285
+ return json.loads(response.read().decode())
286
+ except (urllib.error.URLError, OSError, ValueError):
287
+ return None
288
+
289
+
290
+ _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*$')
291
+
292
+
293
+ def launchd_override(label: str) -> Optional[str]:
294
+ """The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
295
+ none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
296
+
297
+ Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
298
+ older releases ``=> true|false`` (true means disabled)."""
299
+ result = _launchctl("print-disabled", _domain())
300
+ if result.returncode != 0:
301
+ return "unknown"
302
+ for line in result.stdout.splitlines():
303
+ match = _OVERRIDE_ROW.match(line)
304
+ if match and match.group(1) == label:
305
+ return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
306
+ return None
307
+
308
+
309
+ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
310
+ service_id: str, instance: str = "default", timeout: float = 40.0,
311
+ force_enable: bool = False) -> Dict[str, Any]:
312
+ """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
313
+ poll the well-known document until it answers with this identity.
314
+
315
+ The operator's intent wins (LC-14): only a first install (no override recorded) enables the
316
+ label. A label the operator disabled stays disabled and unloaded — the plist is still
317
+ rewritten, so the new release is what starts once they turn it back on — and the answer is
318
+ ``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
319
+ ``force_enable`` is for an operator who asked for the service to be switched on."""
320
+ plist_path = Path(plist_path)
321
+ atomic_write(plist_path, plist_bytes, 0o644)
322
+ lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
323
+ if lint.returncode != 0:
324
+ raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
325
+ target = "%s/%s" % (_domain(), label)
326
+ override = launchd_override(label)
327
+ if force_enable or override is None:
328
+ _launchctl("enable", target)
329
+ elif override == "disabled":
330
+ return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
331
+ _launchctl("bootout", target)
332
+ deadline = time.time() + 15
333
+ while launchd_loaded(label) and time.time() < deadline:
334
+ time.sleep(0.25)
335
+ last = ""
336
+ for _ in range(5):
337
+ result = _launchctl("bootstrap", _domain(), str(plist_path))
338
+ if result.returncode == 0:
339
+ break
340
+ last = (result.stderr or result.stdout).strip()
341
+ time.sleep(1.0)
342
+ else:
343
+ raise ServiceError("launchctl bootstrap failed: %s" % last)
344
+ deadline = time.time() + timeout
345
+ while time.time() < deadline:
346
+ doc = fetch_well_known(origin)
347
+ if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
348
+ return doc
349
+ time.sleep(0.5)
350
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
351
+
352
+
353
+ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
354
+ service_id: Optional[str] = None) -> Dict[str, Any]:
355
+ """Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
356
+ the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
357
+ override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
358
+ unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
359
+ loaded at ``timeout`` raises, leaving the plist and data in place."""
360
+ if purge and not service_id:
361
+ raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
362
+ target = "%s/%s" % (_domain(), label)
363
+ _launchctl("bootout", target)
364
+ deadline = time.monotonic() + timeout
365
+ while launchd_loaded(label):
366
+ if time.monotonic() >= deadline:
367
+ raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
368
+ time.sleep(0.1)
369
+ try:
370
+ Path(plist_path).unlink()
371
+ plist_removed = True
372
+ except FileNotFoundError:
373
+ plist_removed = False
374
+ _launchctl("enable", target)
375
+ purged: List[str] = []
376
+ if purge:
377
+ for directory in service_dirs(str(service_id)).values():
378
+ if directory.is_symlink():
379
+ directory.unlink()
380
+ elif directory.exists():
381
+ shutil.rmtree(directory)
382
+ else:
383
+ continue
384
+ purged.append(str(directory))
385
+ return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
386
+
387
+
388
+ def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
389
+ """Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
390
+ older release directories, newest first by modification time. ``current`` (a release or a
391
+ symlink to one) is never removed, even after a rollback to an older release. Symlinks and
392
+ files in ``releases_dir`` are left alone. Returns what was removed."""
393
+ if keep < 2:
394
+ raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
395
+ root = Path(releases_dir)
396
+ if not root.is_dir():
397
+ return []
398
+ releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
399
+ releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
400
+ keepers: List[Path] = []
401
+ if current is not None:
402
+ resolved = Path(current).resolve()
403
+ keepers.extend(p for p in releases if p.resolve() == resolved)
404
+ for release in releases:
405
+ if len(keepers) >= keep:
406
+ break
407
+ if release not in keepers:
408
+ keepers.append(release)
409
+ removed = [p for p in releases if p not in keepers]
410
+ for release in removed:
411
+ shutil.rmtree(release)
412
+ return removed
@@ -0,0 +1,120 @@
1
+ """Dates and times the way the contract reads them, once for the whole kit (FAA-65, layer L0).
2
+
3
+ - ``parse_rfc3339_ms(text)``: strict RFC 3339 (a ``T``, an offset, a day that exists; no space, no leap second) as
4
+ milliseconds since the epoch, or NaN. ``estate_time`` in older code is the same function.
5
+ - ``clock_ms(at)``: a verifier's clock (a timezone-aware ``datetime`` or epoch seconds) as milliseconds, or NaN.
6
+ - ``utc_date_ms(text)``: a ``YYYY-MM-DD`` that exists as midnight UTC in milliseconds, or NaN.
7
+ - ``utc_day(ms)``, ``format_rfc3339(ms)``, ``day_window(today_ms, days)``, ``now_ms(clock)``: the helpers a service
8
+ needs to write dates and to walk the last N UTC days.
9
+
10
+ NaN, never an exception, is the answer for what cannot be read: callers compare it and get a finding.
11
+ Standard library only; imports only ``fabric_jsvalue``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import math
17
+ import re
18
+ from typing import Any, Callable, List, Optional
19
+ import sys as _sys # noqa: E402
20
+ from pathlib import Path as _Path # noqa: E402
21
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
22
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
23
+ from fabric_jsvalue import is_number
24
+
25
+
26
+ # ASCII digits only: Python's \d also matches other scripts' digits, which Date.parse never reads.
27
+ _RFC3339 = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))", re.ASCII)
28
+
29
+
30
+ def days_from_civil(year: int, month: int, day: int) -> int:
31
+ year -= month <= 2
32
+ era = (year if year >= 0 else year - 399) // 400
33
+ yoe = year - era * 400
34
+ doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
35
+ doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
36
+ return era * 146097 + doe - 719468
37
+
38
+
39
+ def parse_rfc3339_ms(value: Any) -> float:
40
+ """An RFC 3339 date-time as milliseconds since the epoch, or NaN: `T`, an offset and a day that exists are required;
41
+ a space and a leap second `:60` are refused (ER-10). Fractions count to the millisecond, as Date.parse reads them."""
42
+ match = _RFC3339.fullmatch(value) if isinstance(value, str) else None
43
+ if not match:
44
+ return math.nan
45
+ year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
46
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
47
+ days_in = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
48
+ if not (1 <= month <= 12 and 1 <= day <= days_in[month - 1]) or hour > 23 or minute > 59 or second > 59:
49
+ return math.nan
50
+ if match.group(9):
51
+ oh, om = int(match.group(10)), int(match.group(11))
52
+ if oh > 23 or om > 59:
53
+ return math.nan
54
+ offset = (oh * 60 + om) * (1 if match.group(9) == "+" else -1)
55
+ else:
56
+ offset = 0
57
+ millis = int((match.group(7) or ".")[1:4].ljust(3, "0"))
58
+ seconds = days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset * 60
59
+ return float(seconds * 1000 + millis)
60
+
61
+
62
+ def clock_ms(at: Any) -> float:
63
+ """The verifier's clock as milliseconds: a timezone-aware datetime or epoch seconds; NaN for anything else."""
64
+ if isinstance(at, _dt.datetime) and at.tzinfo is not None:
65
+ return at.timestamp() * 1000.0
66
+ if is_number(at):
67
+ try:
68
+ seconds = float(at)
69
+ except OverflowError:
70
+ return math.nan
71
+ return seconds * 1000.0 if math.isfinite(seconds) else math.nan
72
+ return math.nan
73
+
74
+
75
+ DAY_MS = 86_400_000
76
+ _UTC_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", re.ASCII)
77
+
78
+
79
+ def utc_date_ms(value: Any) -> float:
80
+ """A ``YYYY-MM-DD`` date that exists, as midnight UTC in milliseconds; NaN for anything else (the contract's
81
+ ``utcDate``)."""
82
+ match = _UTC_DATE.fullmatch(value) if isinstance(value, str) else None
83
+ if not match:
84
+ return math.nan
85
+ try:
86
+ day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
87
+ except ValueError:
88
+ return math.nan
89
+ return float((day - _dt.date(1970, 1, 1)).days * DAY_MS)
90
+
91
+
92
+ def utc_day(ms: float) -> str:
93
+ """The UTC calendar day (``YYYY-MM-DD``) of an instant in milliseconds. Raises ValueError for NaN or Infinity."""
94
+ if not math.isfinite(ms):
95
+ raise ValueError("not a finite instant")
96
+ return (_dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=ms)).strftime("%Y-%m-%d")
97
+
98
+
99
+ def format_rfc3339(ms: float) -> str:
100
+ """An instant as RFC 3339 in UTC with milliseconds (``2026-10-21T09:00:00.000Z``), which ``parse_rfc3339_ms``
101
+ reads back to the same value. Raises ValueError for NaN or Infinity."""
102
+ if not math.isfinite(ms):
103
+ raise ValueError("not a finite instant")
104
+ moment = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=int(ms))
105
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (moment.microsecond // 1000)
106
+
107
+
108
+ def day_window(today_ms: float, days: int) -> List[str]:
109
+ """The last ``days`` UTC days ending with the day of ``today_ms``, oldest first (a usage report's window is 31)."""
110
+ if days < 1:
111
+ return []
112
+ start = (today_ms // DAY_MS) * DAY_MS - (days - 1) * DAY_MS
113
+ return [utc_day(start + i * DAY_MS) for i in range(days)]
114
+
115
+
116
+ def now_ms(clock: Optional[Callable[[], float]] = None) -> float:
117
+ """Milliseconds since the epoch: from ``clock`` (seconds, as ``time.time``) when given, so a test fixes the time."""
118
+ import time
119
+ return float((clock or time.time)()) * 1000.0
120
+