@passioncode-ai/fabric-agent-adapter 0.16.0 → 0.18.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 +72 -0
- package/README.md +2 -2
- 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 +7 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +342 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +308 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -18,13 +18,16 @@ import hashlib
|
|
|
18
18
|
import hmac
|
|
19
19
|
import http.server
|
|
20
20
|
import json
|
|
21
|
+
import ntpath
|
|
21
22
|
import os
|
|
22
|
-
from pathlib import Path
|
|
23
|
+
from pathlib import Path, PurePath
|
|
23
24
|
import plistlib
|
|
25
|
+
import posixpath
|
|
24
26
|
import re
|
|
25
27
|
import secrets
|
|
26
28
|
import shutil
|
|
27
29
|
import signal
|
|
30
|
+
import socket
|
|
28
31
|
import socketserver
|
|
29
32
|
import stat
|
|
30
33
|
import subprocess
|
|
@@ -185,15 +188,15 @@ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
|
185
188
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
189
|
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
187
190
|
try:
|
|
191
|
+
if WINDOWS:
|
|
192
|
+
# The file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in — so
|
|
193
|
+
# it does not depend on what the folder hands down. A rename keeps it.
|
|
194
|
+
_win_protect(Path(tmp), folder=False)
|
|
188
195
|
with os.fdopen(fd, "wb") as handle:
|
|
189
196
|
handle.write(data)
|
|
190
197
|
handle.flush()
|
|
191
198
|
os.fsync(handle.fileno())
|
|
192
|
-
if WINDOWS:
|
|
193
|
-
# The file's ACL is its privacy (DEC-0032): set explicitly and protected, so it does
|
|
194
|
-
# not depend on what the folder hands down. A rename keeps it.
|
|
195
|
-
_win_protect(Path(tmp), folder=False)
|
|
196
|
-
else:
|
|
199
|
+
if not WINDOWS:
|
|
197
200
|
os.chmod(tmp, mode)
|
|
198
201
|
os.replace(tmp, path)
|
|
199
202
|
except BaseException:
|
|
@@ -217,6 +220,7 @@ class InstanceLock:
|
|
|
217
220
|
def __init__(self, data_dir: Path):
|
|
218
221
|
self.path = Path(data_dir) / "service.lock"
|
|
219
222
|
self._fd: Optional[int] = None
|
|
223
|
+
self._name: Any = None
|
|
220
224
|
|
|
221
225
|
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
222
226
|
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
@@ -242,6 +246,11 @@ class InstanceLock:
|
|
|
242
246
|
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
243
247
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
244
248
|
raise
|
|
249
|
+
try:
|
|
250
|
+
self._name = _hold_lock_name(self.path)
|
|
251
|
+
except BaseException:
|
|
252
|
+
os.close(fd)
|
|
253
|
+
raise
|
|
245
254
|
os.ftruncate(fd, 0)
|
|
246
255
|
os.write(fd, str(os.getpid()).encode())
|
|
247
256
|
os.fsync(fd)
|
|
@@ -252,6 +261,70 @@ class InstanceLock:
|
|
|
252
261
|
if self._fd is not None:
|
|
253
262
|
os.close(self._fd)
|
|
254
263
|
self._fd = None
|
|
264
|
+
if self._name is not None:
|
|
265
|
+
_drop_lock_name(self._name)
|
|
266
|
+
self._name = None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# The Node kit cannot flock on Linux or lock a byte range on Windows, so it holds a name instead: an
|
|
270
|
+
# abstract unix socket on Linux, a named pipe on Windows — exclusive, no file, gone with the process.
|
|
271
|
+
# This kit holds the same name beside its file lock, so the two kits exclude each other everywhere
|
|
272
|
+
# and the probe sees either one (`lock_name_held`).
|
|
273
|
+
|
|
274
|
+
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
275
|
+
"""The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
|
|
276
|
+
if platform.startswith("linux"):
|
|
277
|
+
digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
|
|
278
|
+
return "\0fabric-service-" + digest
|
|
279
|
+
if platform == "win32":
|
|
280
|
+
# Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
281
|
+
digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
|
|
282
|
+
return "\\\\.\\pipe\\fabric-service-" + digest
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _hold_lock_name(lock_path: Path) -> Any:
|
|
287
|
+
name = lock_name(lock_path)
|
|
288
|
+
if name is None:
|
|
289
|
+
return None
|
|
290
|
+
if WINDOWS:
|
|
291
|
+
handle = _create_first_pipe(name)
|
|
292
|
+
if handle is None:
|
|
293
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path)
|
|
294
|
+
return handle
|
|
295
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
296
|
+
try:
|
|
297
|
+
sock.bind(name)
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
sock.close()
|
|
300
|
+
if exc.errno == errno.EADDRINUSE:
|
|
301
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
|
|
302
|
+
raise
|
|
303
|
+
# Held by its descriptor, like the file lock: a socket object would close when collected.
|
|
304
|
+
return sock.detach()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _drop_lock_name(held: Any) -> None:
|
|
308
|
+
if WINDOWS:
|
|
309
|
+
_close_handle(held)
|
|
310
|
+
else:
|
|
311
|
+
os.close(held)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def lock_name_held(lock_path: Path) -> Optional[bool]:
|
|
315
|
+
"""True when some process holds the lock's name (a running service of either kit); None where there is no name
|
|
316
|
+
to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
|
|
317
|
+
probe cannot make a service that is starting at that moment fail."""
|
|
318
|
+
name = lock_name(lock_path)
|
|
319
|
+
if name is None:
|
|
320
|
+
return None
|
|
321
|
+
if WINDOWS:
|
|
322
|
+
return _pipe_exists(name)
|
|
323
|
+
try:
|
|
324
|
+
with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
|
|
325
|
+
return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
|
|
326
|
+
except OSError:
|
|
327
|
+
return None
|
|
255
328
|
|
|
256
329
|
|
|
257
330
|
def _read_pid(path: Path) -> Optional[int]:
|
|
@@ -262,6 +335,15 @@ def _read_pid(path: Path) -> Optional[int]:
|
|
|
262
335
|
return None
|
|
263
336
|
|
|
264
337
|
|
|
338
|
+
SUPERVISORS = ("launchd", "systemd", "task-scheduler")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def supervised() -> bool:
|
|
342
|
+
"""True when a supervisor that restarts on exit started this process and said so in
|
|
343
|
+
``FABRIC_SERVICE_SUPERVISOR`` (``launchd_plist`` and ``systemd_unit`` set it)."""
|
|
344
|
+
return os.environ.get(SUPERVISOR_ENV) in SUPERVISORS
|
|
345
|
+
|
|
346
|
+
|
|
265
347
|
def supervised_by_launchd() -> bool:
|
|
266
348
|
"""True when launchd started this process from a plist written by ``launchd_plist``.
|
|
267
349
|
|
|
@@ -281,7 +363,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
281
363
|
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
282
364
|
``wait_seconds`` overrides the choice (0 = never wait)."""
|
|
283
365
|
if wait_seconds is None:
|
|
284
|
-
wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if
|
|
366
|
+
wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised() else 0.0
|
|
285
367
|
deadline = clock() + max(0.0, wait_seconds)
|
|
286
368
|
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
287
369
|
announced = False
|
|
@@ -440,6 +522,35 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
|
440
522
|
finally:
|
|
441
523
|
_kernel32.LocalFree(sd)
|
|
442
524
|
|
|
525
|
+
_kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
|
|
526
|
+
_kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
|
|
527
|
+
_INVALID_HANDLE = wintypes.HANDLE(-1).value
|
|
528
|
+
|
|
529
|
+
def _create_first_pipe(name: str) -> Any:
|
|
530
|
+
"""The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
|
|
531
|
+
# PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
|
|
532
|
+
# as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
|
|
533
|
+
# ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
|
|
534
|
+
handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
|
|
535
|
+
if handle == _INVALID_HANDLE:
|
|
536
|
+
code = ctypes.get_last_error()
|
|
537
|
+
if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
|
|
538
|
+
return None
|
|
539
|
+
raise _win_error("CreateNamedPipeW", code)
|
|
540
|
+
return handle
|
|
541
|
+
|
|
542
|
+
def _close_handle(handle: Any) -> None:
|
|
543
|
+
_kernel32.CloseHandle(handle)
|
|
544
|
+
|
|
545
|
+
_kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
|
|
546
|
+
_kernel32.WaitNamedPipeW.restype = wintypes.BOOL
|
|
547
|
+
|
|
548
|
+
def _pipe_exists(name: str) -> bool:
|
|
549
|
+
"""Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
|
|
550
|
+
if _kernel32.WaitNamedPipeW(name, 1):
|
|
551
|
+
return True
|
|
552
|
+
return ctypes.get_last_error() != 2
|
|
553
|
+
|
|
443
554
|
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
|
|
444
555
|
if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
|
|
445
556
|
return "Token file %s is not a regular file (a link or junction); refusing it." % path
|
|
@@ -450,6 +561,15 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
|
450
561
|
owner, granting = windows_acl(path)
|
|
451
562
|
return token_acl_problem(path, owner, granting, windows_user_sid())
|
|
452
563
|
else:
|
|
564
|
+
def _create_first_pipe(name: str) -> Any: # noqa: ARG001
|
|
565
|
+
raise ServiceError("named pipes exist only on Windows")
|
|
566
|
+
|
|
567
|
+
def _close_handle(handle: Any) -> None: # noqa: ARG001
|
|
568
|
+
raise ServiceError("handles exist only on Windows")
|
|
569
|
+
|
|
570
|
+
def _pipe_exists(name: str) -> bool: # noqa: ARG001
|
|
571
|
+
raise ServiceError("named pipes exist only on Windows")
|
|
572
|
+
|
|
453
573
|
def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
|
|
454
574
|
raise ServiceError("Windows ACLs exist only on Windows")
|
|
455
575
|
|
|
@@ -1516,6 +1636,187 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
|
|
|
1516
1636
|
|
|
1517
1637
|
# --- launchd -----------------------------------------------------------------
|
|
1518
1638
|
|
|
1639
|
+
# --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
|
|
1640
|
+
|
|
1641
|
+
def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
|
|
1642
|
+
"""No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
|
|
1643
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
1644
|
+
for arg in argv:
|
|
1645
|
+
if fabric_secrets.secret_argument(arg):
|
|
1646
|
+
raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
|
|
1647
|
+
for name, value in (environment or {}).items():
|
|
1648
|
+
if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
|
|
1649
|
+
raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _plain(value: str, what: str) -> str:
|
|
1653
|
+
"""A supervisor file is line- and XML-structured: no control character in anything written into it."""
|
|
1654
|
+
if re.search(r"[\x00-\x1f\x7f]", value):
|
|
1655
|
+
raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
|
|
1656
|
+
return value
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _systemd_quote(value: str) -> str:
|
|
1660
|
+
_plain(value, "an argument")
|
|
1661
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
|
|
1662
|
+
return '"%s"' % escaped
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
1666
|
+
environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
|
|
1667
|
+
stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
|
|
1668
|
+
"""A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
1669
|
+
WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
|
|
1670
|
+
if not _UNIT.match(unit):
|
|
1671
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
1672
|
+
argv = [str(a) for a in program_arguments]
|
|
1673
|
+
if not argv or not argv[0].startswith("/"):
|
|
1674
|
+
raise ServiceError("a systemd unit starts an absolute executable")
|
|
1675
|
+
if stop_timeout <= DRAIN_SECONDS:
|
|
1676
|
+
raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
|
|
1677
|
+
env = dict(environment or {})
|
|
1678
|
+
_no_secret_in_supervisor_file(argv, env)
|
|
1679
|
+
env.setdefault(SUPERVISOR_ENV, "systemd")
|
|
1680
|
+
# A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
|
|
1681
|
+
cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
|
|
1682
|
+
"the working directory")
|
|
1683
|
+
if not cwd.startswith("/"):
|
|
1684
|
+
raise ServiceError("a systemd unit's working directory is absolute")
|
|
1685
|
+
lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
|
|
1686
|
+
"[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
|
|
1687
|
+
# Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
1688
|
+
"WorkingDirectory=%s" % cwd.replace("%", "%%")]
|
|
1689
|
+
for name in env:
|
|
1690
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
|
1691
|
+
raise ServiceError("environment name %r is not a variable name" % name)
|
|
1692
|
+
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
|
|
1693
|
+
lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
|
|
1694
|
+
"[Install]", "WantedBy=default.target", ""]
|
|
1695
|
+
return "\n".join(lines)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def _systemd_dir() -> Path:
|
|
1699
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _check(result: Any, what: str) -> None:
|
|
1703
|
+
if getattr(result, "returncode", 1) != 0:
|
|
1704
|
+
raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
1708
|
+
system: str = sys.platform, force_enable: bool = False) -> Path:
|
|
1709
|
+
"""Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
1710
|
+
|
|
1711
|
+
The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
|
|
1712
|
+
the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
|
|
1713
|
+
if not system.startswith("linux"):
|
|
1714
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
1715
|
+
if not _UNIT.match(unit):
|
|
1716
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
1717
|
+
folder = unit_dir or _systemd_dir()
|
|
1718
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
1719
|
+
path = folder / unit
|
|
1720
|
+
first = not path.exists()
|
|
1721
|
+
state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
|
|
1722
|
+
"stdout", "") or "").strip()
|
|
1723
|
+
atomic_write(path, text.encode("utf-8"), 0o644)
|
|
1724
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
1725
|
+
if state == "disabled" and not force_enable:
|
|
1726
|
+
return path
|
|
1727
|
+
_check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
|
|
1728
|
+
_check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
|
|
1729
|
+
return path
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
1733
|
+
system: str = sys.platform) -> None:
|
|
1734
|
+
"""Stop it and keep it off across logins (`disable --now`), then remove the unit."""
|
|
1735
|
+
if not system.startswith("linux"):
|
|
1736
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
1737
|
+
run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
|
|
1738
|
+
path = (unit_dir or _systemd_dir()) / unit
|
|
1739
|
+
if path.exists():
|
|
1740
|
+
path.unlink()
|
|
1741
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _xml(text: str) -> str:
|
|
1745
|
+
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
|
|
1749
|
+
user: Optional[str] = None) -> str:
|
|
1750
|
+
"""A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
|
|
1751
|
+
run only when the user is logged on, created without administrator rights."""
|
|
1752
|
+
if not _TASK.match(task):
|
|
1753
|
+
raise ServiceError("task must match %s" % _TASK.pattern)
|
|
1754
|
+
argv = [str(a) for a in program_arguments]
|
|
1755
|
+
if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
|
|
1756
|
+
raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
|
|
1757
|
+
for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
|
|
1758
|
+
(description or task, "the description")]:
|
|
1759
|
+
_plain(value, what)
|
|
1760
|
+
_no_secret_in_supervisor_file(argv, None)
|
|
1761
|
+
who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
|
|
1762
|
+
if not who:
|
|
1763
|
+
raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
|
|
1764
|
+
_plain(who, "the user")
|
|
1765
|
+
body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1766
|
+
<RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
|
|
1767
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
|
|
1768
|
+
<Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
1769
|
+
<Settings>
|
|
1770
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
1771
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
1772
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
1773
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
1774
|
+
<Enabled>true</Enabled>
|
|
1775
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
1776
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
1777
|
+
</Settings>
|
|
1778
|
+
<Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
|
|
1779
|
+
</Task>
|
|
1780
|
+
""".format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
|
|
1781
|
+
arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
|
|
1782
|
+
return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
_TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
|
|
1789
|
+
force_enable: bool = False) -> None:
|
|
1790
|
+
"""Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
|
|
1791
|
+
definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
|
|
1792
|
+
if system != "win32":
|
|
1793
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
1794
|
+
query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
|
|
1795
|
+
existed = getattr(query, "returncode", 1) == 0
|
|
1796
|
+
keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
|
|
1797
|
+
fd, name = tempfile.mkstemp(suffix=".xml")
|
|
1798
|
+
try:
|
|
1799
|
+
with os.fdopen(fd, "wb") as handle:
|
|
1800
|
+
handle.write(xml_text.encode("utf-16"))
|
|
1801
|
+
_check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
|
|
1802
|
+
finally:
|
|
1803
|
+
os.unlink(name)
|
|
1804
|
+
if keep_off:
|
|
1805
|
+
_check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
|
|
1806
|
+
return
|
|
1807
|
+
if existed:
|
|
1808
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
1809
|
+
_check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
|
|
1813
|
+
"""End the running task, then delete it."""
|
|
1814
|
+
if system != "win32":
|
|
1815
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
1816
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
1817
|
+
_check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
|
|
1818
|
+
|
|
1819
|
+
|
|
1519
1820
|
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
1520
1821
|
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
1521
1822
|
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
@@ -5,7 +5,7 @@ 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.18.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
11
11
|
---
|