@passioncode-ai/fabric-agent-adapter 0.76.0 → 0.78.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 +126 -0
- package/README.md +22 -6
- package/SECURITY.md +25 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +33 -44
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +325 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +44 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +310 -23
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +209 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
+
from types import MappingProxyType
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
9
10
|
|
|
@@ -33,6 +34,10 @@ EVENTS_MAX_LIMIT = 200
|
|
|
33
34
|
LEVELS = ("info", "notice", "warning", "error")
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
# The dashboard kit names its chip `warn`; an event written at that name is stored at the feed's `warning` (FAA-276).
|
|
38
|
+
LEVEL_ALIASES = MappingProxyType({"warn": "warning"})
|
|
39
|
+
|
|
40
|
+
|
|
36
41
|
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}\Z")
|
|
37
42
|
|
|
38
43
|
|
|
@@ -45,13 +50,15 @@ _SPAN_ID = re.compile(r"^(?!0{16}\Z)[0-9a-f]{16}\Z")
|
|
|
45
50
|
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
46
51
|
link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
|
|
47
52
|
span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
48
|
-
"""One activity event: one sentence a person reads, never a machine id. `
|
|
53
|
+
"""One activity event: one sentence a person reads, never a machine id. `level` is one of `LEVELS`; `warn`, the
|
|
54
|
+
dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable
|
|
49
55
|
fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
|
|
50
56
|
|
|
51
57
|
An event about traced work carries its trace as a pair, `trace_id` and `span_id`
|
|
52
58
|
(fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
|
|
59
|
+
level = LEVEL_ALIASES.get(level, level) if isinstance(level, str) else level
|
|
53
60
|
if level not in LEVELS:
|
|
54
|
-
raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
|
|
61
|
+
raise ServiceError("level must be one of %s (or `warn` for `warning`)." % ", ".join(LEVELS))
|
|
55
62
|
if not _KIND.match(kind):
|
|
56
63
|
raise ServiceError("kind %r must be dotted lowercase." % kind)
|
|
57
64
|
text = " ".join(str(text).split())
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""peer_owner: which OS account owns each end of a loopback TCP connection.
|
|
2
2
|
|
|
3
|
-
peer_owner 1.1.0+kit.
|
|
3
|
+
peer_owner 1.1.0+kit.3: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
|
|
4
4
|
DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
|
|
5
5
|
commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
|
|
6
6
|
from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
|
|
19
19
|
macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
|
|
20
20
|
`TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
|
|
21
|
-
`parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`).
|
|
21
|
+
`parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
|
|
22
|
+
the child's port is waited on with `selectors` and read under one deadline (FAA-273).
|
|
22
23
|
A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
|
|
23
24
|
created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
|
|
24
25
|
The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
|
|
@@ -109,7 +110,7 @@ import time
|
|
|
109
110
|
from typing import NamedTuple, Optional, Tuple
|
|
110
111
|
|
|
111
112
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
112
|
-
PEER_OWNER_VERSION = "1.1.0+kit.
|
|
113
|
+
PEER_OWNER_VERSION = "1.1.0+kit.3" # Observatory's 1.1.0 with the kit's differences above
|
|
113
114
|
|
|
114
115
|
THIS_ACCOUNT = "this-account"
|
|
115
116
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -500,10 +501,34 @@ _CHILD_LISTENER = ("import socket,sys\ns=socket.socket();s.bind(('127.0.0.1',0))
|
|
|
500
501
|
"print(s.getsockname()[1],flush=True);sys.stdin.read()")
|
|
501
502
|
|
|
502
503
|
|
|
504
|
+
def _child_line(child: "subprocess.Popen[bytes]", deadline: float) -> Optional[bytes]:
|
|
505
|
+
"""The child's first line, or None when no newline came by `deadline`. It waits with `selectors` (kqueue on
|
|
506
|
+
macOS: no FD_SETSIZE ceiling at fd 1024, which `select.select` refuses) and reads what is there, so a partial line
|
|
507
|
+
from an interpreter that then waits cannot hold the caller past the deadline (FAA-273 item 13, and the `selectors`
|
|
508
|
+
half of item 11)."""
|
|
509
|
+
import selectors # POSIX: this reader runs on macOS alone
|
|
510
|
+
got = b""
|
|
511
|
+
try:
|
|
512
|
+
with selectors.DefaultSelector() as sel:
|
|
513
|
+
sel.register(child.stdout, selectors.EVENT_READ)
|
|
514
|
+
while b"\n" not in got and len(got) < 64:
|
|
515
|
+
left = deadline - time.monotonic()
|
|
516
|
+
if left <= 0 or not sel.select(left):
|
|
517
|
+
return None
|
|
518
|
+
chunk = os.read(child.stdout.fileno(), 64)
|
|
519
|
+
if not chunk:
|
|
520
|
+
return None
|
|
521
|
+
got += chunk
|
|
522
|
+
except (OSError, ValueError):
|
|
523
|
+
return None
|
|
524
|
+
return got.split(b"\n", 1)[0] if b"\n" in got else None
|
|
525
|
+
|
|
526
|
+
|
|
503
527
|
def _child_listener_seen(rows_of) -> Optional[bool]:
|
|
504
528
|
"""Whether the table shows a listener of a child process of this one: False when it does not (the table shows
|
|
505
|
-
this process alone, FAA-267), None when no child could be started or
|
|
506
|
-
|
|
529
|
+
this process alone, FAA-267), None when no child could be started (a frozen or embedded host has no interpreter to
|
|
530
|
+
start), or it gave no port line within TIMEOUT_SECONDS (silence, a partial or non-numeric line, an early exit, a
|
|
531
|
+
pipe that cannot be read). The calibration trusts only True."""
|
|
507
532
|
if not sys.executable or getattr(sys, "frozen", False):
|
|
508
533
|
return None
|
|
509
534
|
try:
|
|
@@ -511,21 +536,25 @@ def _child_listener_seen(rows_of) -> Optional[bool]:
|
|
|
511
536
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
512
537
|
except OSError:
|
|
513
538
|
return None
|
|
539
|
+
deadline = time.monotonic() + TIMEOUT_SECONDS
|
|
540
|
+
port = None
|
|
514
541
|
try:
|
|
515
|
-
|
|
516
|
-
if not
|
|
517
|
-
return None
|
|
518
|
-
line = child.stdout.readline().strip()
|
|
519
|
-
if not line.isdigit():
|
|
542
|
+
line = _child_line(child, deadline)
|
|
543
|
+
if line is None or not line.strip().isdigit():
|
|
520
544
|
return None
|
|
521
|
-
port = int(line)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
545
|
+
port = int(line.strip())
|
|
546
|
+
try:
|
|
547
|
+
return any(r.listening and r.local == ("127.0.0.1", port) and r.pid == child.pid for r in rows_of())
|
|
548
|
+
except (TableUnreadable, OSError, struct.error, ValueError):
|
|
549
|
+
return False
|
|
525
550
|
finally:
|
|
551
|
+
# One deadline for the whole check: a child that gave no port is killed at once, and one that did gets what is
|
|
552
|
+
# left of the deadline to exit on its closed stdin before it is killed.
|
|
526
553
|
try:
|
|
527
554
|
child.stdin.close()
|
|
528
|
-
|
|
555
|
+
if port is None:
|
|
556
|
+
child.kill()
|
|
557
|
+
child.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
529
558
|
except (OSError, subprocess.TimeoutExpired):
|
|
530
559
|
child.kill()
|
|
531
560
|
child.wait()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -275,6 +275,7 @@ from fabric_auth import ( # noqa: F401
|
|
|
275
275
|
from fabric_events import ( # noqa: F401
|
|
276
276
|
EVENTS_DEFAULT_LIMIT,
|
|
277
277
|
EVENTS_MAX_LIMIT,
|
|
278
|
+
LEVEL_ALIASES,
|
|
278
279
|
LEVELS,
|
|
279
280
|
_KIND,
|
|
280
281
|
_TRACE_ID,
|
|
@@ -313,6 +314,8 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
313
314
|
launchd_plist,
|
|
314
315
|
_launchctl,
|
|
315
316
|
_domain,
|
|
317
|
+
SYSTEM_DAEMONS,
|
|
318
|
+
launchd_domain,
|
|
316
319
|
launchd_loaded,
|
|
317
320
|
launchd_pid,
|
|
318
321
|
fetch_well_known,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -45,6 +45,10 @@ DEFAULT_EXIT_TIMEOUT = 15
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
|
|
48
|
+
_USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
|
|
49
|
+
|
|
50
|
+
#: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
|
|
51
|
+
SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
def _no_marker_argument(argv: Sequence[str]) -> None:
|
|
@@ -255,7 +259,7 @@ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, syste
|
|
|
255
259
|
|
|
256
260
|
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
257
261
|
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
258
|
-
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
262
|
+
exit_timeout: int = DEFAULT_EXIT_TIMEOUT, user_name: Optional[str] = None) -> bytes:
|
|
259
263
|
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
|
|
260
264
|
|
|
261
265
|
``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
|
|
@@ -264,7 +268,10 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
|
|
|
264
268
|
|
|
265
269
|
The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
|
|
266
270
|
not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
|
|
267
|
-
an argument shaped like a credential, and a control character in any string the plist carries.
|
|
271
|
+
an argument shaped like a credential, and a control character in any string the plist carries.
|
|
272
|
+
|
|
273
|
+
``user_name`` writes ``UserName``, the account a LaunchDaemon (system domain, FAA-278) runs as; it must be a macOS
|
|
274
|
+
short name, and it is meaningful only for a plist installed in ``/Library/LaunchDaemons``."""
|
|
268
275
|
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
269
276
|
if not _LABEL.match(str(label)):
|
|
270
277
|
raise ServiceError("label must match %s" % _LABEL.pattern)
|
|
@@ -273,6 +280,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
|
|
|
273
280
|
raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
|
|
274
281
|
if not exit_timeout > DRAIN_SECONDS:
|
|
275
282
|
raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
|
|
283
|
+
if user_name is not None and not _USER_NAME.match(str(user_name)):
|
|
284
|
+
raise ServiceError("UserName must be a macOS short name matching %s" % _USER_NAME.pattern)
|
|
276
285
|
env = dict(environment or {})
|
|
277
286
|
for key, value in env.items():
|
|
278
287
|
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
|
|
@@ -303,6 +312,8 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
|
|
|
303
312
|
"StandardErrorPath": str(stdout_path),
|
|
304
313
|
"EnvironmentVariables": env,
|
|
305
314
|
}
|
|
315
|
+
if user_name is not None:
|
|
316
|
+
plist["UserName"] = str(user_name)
|
|
306
317
|
return plistlib.dumps(plist)
|
|
307
318
|
|
|
308
319
|
|
|
@@ -317,16 +328,52 @@ def _domain() -> str:
|
|
|
317
328
|
return "gui/%d" % os.getuid()
|
|
318
329
|
|
|
319
330
|
|
|
320
|
-
def
|
|
321
|
-
|
|
331
|
+
def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = None) -> str:
|
|
332
|
+
"""The launchd domain a job lives in: ``system`` for a LaunchDaemon (``domain="system"``, or a plist in
|
|
333
|
+
``/Library/LaunchDaemons``), else this user's ``gui/<uid>`` (FAA-278). A LaunchDaemon starts at boot, before any
|
|
334
|
+
login, so it is the form for a headless Mac that a FileVault restart (``fdesetup authrestart``) leaves with no GUI
|
|
335
|
+
session; a ``gui/<uid>`` job starts only while its user is logged in."""
|
|
336
|
+
if domain not in (None, "gui", "system"):
|
|
337
|
+
raise ServiceError("domain must be gui or system")
|
|
338
|
+
if domain is None and plist_path is not None:
|
|
339
|
+
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
|
|
340
|
+
domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
|
|
341
|
+
if domain == "system":
|
|
342
|
+
if sys.platform != "darwin":
|
|
343
|
+
raise ServiceError("launchd is the supervisor on macOS only; use systemd_install on Linux and task_install on "
|
|
344
|
+
"Windows.")
|
|
345
|
+
return "system"
|
|
346
|
+
return _domain()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
|
|
350
|
+
"""A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
|
|
351
|
+
if domain != "system" or os.geteuid() == 0:
|
|
352
|
+
return
|
|
353
|
+
if install:
|
|
354
|
+
raise ServiceError("A LaunchDaemon (launchd's system domain) is installed by root, and this process is not "
|
|
355
|
+
"root: run launchd_install as root (the release, descriptor and token stay its UserName's, "
|
|
356
|
+
"lifecycle reference, The system domain), or as root write %s and run "
|
|
357
|
+
"`launchctl bootstrap system %s`. Status and pid read without root." % (plist_path, plist_path))
|
|
358
|
+
raise ServiceError("A LaunchDaemon (launchd's system domain) is removed by root, and this process is not root: run "
|
|
359
|
+
"launchd_uninstall as root, or as root run `launchctl bootout system/%s`, remove %s and run "
|
|
360
|
+
"`launchctl enable system/%s` (so a later install is not kept off). Status and pid read without "
|
|
361
|
+
"root." % (label, plist_path, label))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def launchd_loaded(label: str, domain: Optional[str] = None) -> bool:
|
|
365
|
+
"""Whether launchd has the job ``label`` loaded in this user's ``gui/<uid>`` domain, or with ``domain="system"`` in
|
|
366
|
+
the system domain (FAA-278); reading needs no root."""
|
|
367
|
+
return _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label)).returncode == 0
|
|
322
368
|
|
|
323
369
|
|
|
324
370
|
_PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
|
|
325
371
|
|
|
326
372
|
|
|
327
|
-
def launchd_pid(label: str) -> Optional[int]:
|
|
328
|
-
"""The running pid of this account's launchd job ``label``,
|
|
329
|
-
|
|
373
|
+
def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
|
|
374
|
+
"""The running pid of this account's launchd job ``label`` (with ``domain="system"``, of the LaunchDaemon), or
|
|
375
|
+
None (not running, or not readable). Reading needs no root."""
|
|
376
|
+
result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
|
|
330
377
|
if result.returncode != 0:
|
|
331
378
|
return None
|
|
332
379
|
for line in (result.stdout or "").splitlines():
|
|
@@ -340,7 +387,8 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
340
387
|
vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
341
388
|
"""(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
|
|
342
389
|
connection whose server end is this OS account's process (DEC-0154 HA-2): another account's, root's, or one whose
|
|
343
|
-
owner cannot be told is
|
|
390
|
+
owner cannot be told is never this service's answer, labelled `foreign-user`, or `table-restricted` when the socket
|
|
391
|
+
table hides other processes. `vouch_pid`, the pid of a launchd job this
|
|
344
392
|
caller started, proves the listener when the socket table hides other processes (FAA-267)."""
|
|
345
393
|
parts = urllib.parse.urlsplit(origin)
|
|
346
394
|
if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
|
|
@@ -363,6 +411,107 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
363
411
|
return None, None
|
|
364
412
|
|
|
365
413
|
|
|
414
|
+
def _job_listens(pid: int, port: int) -> bool:
|
|
415
|
+
"""Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
|
|
416
|
+
(`lsof -p`), which a restricted socket table does not hide. Used only for a LaunchDaemon root just bootstrapped,
|
|
417
|
+
whose pid launchd itself named (FAA-278)."""
|
|
418
|
+
if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
|
|
419
|
+
return False
|
|
420
|
+
try:
|
|
421
|
+
out = _po._run([_po.LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
|
|
422
|
+
except _po.TableUnreadable:
|
|
423
|
+
return False
|
|
424
|
+
return any(host == "127.0.0.1" and p == port for host, p in _po.parse_lsof_listeners(out)) # exact bind, as RT-3
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _account_uid(user_name: str) -> Optional[int]:
|
|
428
|
+
"""The uid of account ``user_name`` on this machine, or None when it has no such account. Only a short name: macOS
|
|
429
|
+
getpwnam also answers an upper-case name or the full name, which launchd's UserName does not take."""
|
|
430
|
+
if not isinstance(user_name, str) or not _USER_NAME.match(user_name):
|
|
431
|
+
return None
|
|
432
|
+
try:
|
|
433
|
+
import pwd
|
|
434
|
+
return pwd.getpwnam(user_name).pw_uid
|
|
435
|
+
except (ImportError, KeyError, ValueError):
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _pid_uid(pid: int) -> Optional[int]:
|
|
440
|
+
"""The uid process ``pid`` runs as (``ps -o uid= -p``), or None when it is gone or cannot be read."""
|
|
441
|
+
try:
|
|
442
|
+
return _po.parse_ps_uid(_po._run([_po.PS, "-o", "uid=", "-p", str(pid)]))
|
|
443
|
+
except _po.TableUnreadable:
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _positive(value: Any) -> bool:
|
|
448
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class _JobConnection(http.client.HTTPConnection):
|
|
452
|
+
"""A connection whose server end must be launchd's job `job_pid`, running as `job_uid`, its UserName's uid (a
|
|
453
|
+
LaunchDaemon runs as its UserName, so the installer, root, reads it as another account's: the job's own pid and
|
|
454
|
+
uid are the proof, FAA-278; contract DEC-0161, proposed: RT-3 extended to launchd's system domain)."""
|
|
455
|
+
|
|
456
|
+
def __init__(self, host: str, port: int, job_pid: int, timeout: float, job_uid: Optional[int] = None) -> None:
|
|
457
|
+
if not isinstance(job_pid, int) or isinstance(job_pid, bool) or job_pid <= 1:
|
|
458
|
+
raise ServiceError("A LaunchDaemon's answer is read only on its job's pid, a positive number.")
|
|
459
|
+
if not _positive(job_uid):
|
|
460
|
+
raise ServiceError("A LaunchDaemon's answer is read only from its UserName's uid, a number.")
|
|
461
|
+
super().__init__(host, port, timeout=timeout)
|
|
462
|
+
self.job_pid = job_pid
|
|
463
|
+
self.job_uid = job_uid
|
|
464
|
+
|
|
465
|
+
def connect(self) -> None:
|
|
466
|
+
super().connect()
|
|
467
|
+
owner = _po.server_end(self.sock)
|
|
468
|
+
restricted = owner.kind == _po.UNKNOWN and (owner.reason or "").startswith(_po.TABLE_RESTRICTED)
|
|
469
|
+
if owner.pid == self.job_pid and not _job_listens(self.job_pid, self.port):
|
|
470
|
+
self.close()
|
|
471
|
+
# The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
|
|
472
|
+
raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d is not shown listening on exactly "
|
|
473
|
+
"127.0.0.1:%d by lsof (a wildcard is never vouched for); nothing was sent"
|
|
474
|
+
% (self.job_pid, self.port))
|
|
475
|
+
if owner.pid == self.job_pid or (restricted and _job_listens(self.job_pid, self.port)):
|
|
476
|
+
runs_as = _pid_uid(self.job_pid)
|
|
477
|
+
if runs_as == self.job_uid:
|
|
478
|
+
return
|
|
479
|
+
self.close()
|
|
480
|
+
# Not the restricted owner: the pid was proved, only its uid is wrong, and the error says so.
|
|
481
|
+
raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the LaunchDaemon's pid %d runs as uid %s, not its UserName's "
|
|
482
|
+
"uid %d; nothing was sent" % (self.job_pid, runs_as, self.job_uid))
|
|
483
|
+
self.close()
|
|
484
|
+
raise _po.ListenerRefused(_po.SERVER_NOT_OWN, "the server on port %d is not the LaunchDaemon's pid %d (%s): "
|
|
485
|
+
"another process holds the port, or the job's program starts the server as a child "
|
|
486
|
+
"(start it with exec); nothing was sent"
|
|
487
|
+
% (self.port, self.job_pid, owner.reason or owner.kind), owner)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _read_job_well_known(origin: str, job_pid: int, job_uid: Optional[int] = None,
|
|
491
|
+
timeout: float = 2.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
492
|
+
"""As `_read_well_known`, for a LaunchDaemon: a loopback origin is read only when launchd's job `job_pid`, running as
|
|
493
|
+
`job_uid`, accepted the connection; (the answer or None, why the listener was refused or None)."""
|
|
494
|
+
try:
|
|
495
|
+
port = urllib.parse.urlsplit(origin).port
|
|
496
|
+
except ValueError: # a port out of range
|
|
497
|
+
port = None
|
|
498
|
+
if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
|
|
499
|
+
raise ServiceError("A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid "
|
|
500
|
+
"prove it; %s is not." % origin)
|
|
501
|
+
conn = _JobConnection("127.0.0.1", port, job_pid, timeout, job_uid)
|
|
502
|
+
try:
|
|
503
|
+
conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % port})
|
|
504
|
+
response = conn.getresponse()
|
|
505
|
+
return (json.loads(response.read().decode()) if response.status == 200 else None), None
|
|
506
|
+
except _po.ListenerRefused as exc:
|
|
507
|
+
restricted = exc.owner is not None and (exc.owner.reason or "").startswith(_po.TABLE_RESTRICTED)
|
|
508
|
+
return None, "%s: %s" % (_po.TABLE_RESTRICTED if restricted else "foreign-user", exc)
|
|
509
|
+
except (OSError, ValueError, http.client.HTTPException):
|
|
510
|
+
return None, None
|
|
511
|
+
finally:
|
|
512
|
+
conn.close()
|
|
513
|
+
|
|
514
|
+
|
|
366
515
|
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
367
516
|
"""The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
|
|
368
517
|
OS account's process (DEC-0154 HA-2)."""
|
|
@@ -372,13 +521,13 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
|
|
|
372
521
|
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
|
|
373
522
|
|
|
374
523
|
|
|
375
|
-
def launchd_override(label: str) -> Optional[str]:
|
|
524
|
+
def launchd_override(label: str, domain: Optional[str] = None) -> Optional[str]:
|
|
376
525
|
"""The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
|
|
377
526
|
none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
|
|
378
527
|
|
|
379
|
-
Reads ``launchctl print-disabled gui/<uid
|
|
528
|
+
Reads ``launchctl print-disabled gui/<uid>`` (``system`` with ``domain="system"``); macOS 14+ prints ``=> disabled|enabled``,
|
|
380
529
|
older releases ``=> true|false`` (true means disabled)."""
|
|
381
|
-
result = _launchctl("print-disabled",
|
|
530
|
+
result = _launchctl("print-disabled", launchd_domain(domain=domain))
|
|
382
531
|
if result.returncode != 0:
|
|
383
532
|
return "unknown"
|
|
384
533
|
for line in result.stdout.splitlines():
|
|
@@ -388,9 +537,63 @@ def launchd_override(label: str) -> Optional[str]:
|
|
|
388
537
|
return None
|
|
389
538
|
|
|
390
539
|
|
|
540
|
+
_PATH_ROW = re.compile(r"^\s*path\s*=\s*(.+?)\s*\Z")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _plist_field(path: Path, key: str) -> Optional[str]:
|
|
544
|
+
try:
|
|
545
|
+
value = plistlib.loads(Path(path).read_bytes()).get(key)
|
|
546
|
+
except Exception: # unreadable or not a plist: it claims nothing
|
|
547
|
+
return None
|
|
548
|
+
return value if isinstance(value, str) else None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _same_file(a: Any, b: Any) -> bool:
|
|
552
|
+
"""One file, whatever the case of its path (APFS ignores case, realpath keeps it)."""
|
|
553
|
+
try:
|
|
554
|
+
return os.path.samefile(str(a), str(b))
|
|
555
|
+
except OSError:
|
|
556
|
+
return os.path.realpath(str(a)) == os.path.realpath(str(b))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _label_holders(label: str, plist_path: Path) -> List[Path]:
|
|
560
|
+
"""Other plists that hold launchd label ``label`` in the system domain (FAA-278): the plist launchd loaded the job
|
|
561
|
+
from, when it is not ``plist_path``, and any plist of that label beside ``plist_path``. Reading needs no root."""
|
|
562
|
+
found: List[Path] = []
|
|
563
|
+
result = _launchctl("print", "system/%s" % label)
|
|
564
|
+
if result.returncode == 0:
|
|
565
|
+
for line in (result.stdout or "").splitlines():
|
|
566
|
+
hit = _PATH_ROW.match(line)
|
|
567
|
+
if hit:
|
|
568
|
+
found.append(Path(hit.group(1)))
|
|
569
|
+
break
|
|
570
|
+
try:
|
|
571
|
+
siblings = sorted(Path(plist_path).parent.glob("*.plist"))
|
|
572
|
+
except OSError:
|
|
573
|
+
siblings = []
|
|
574
|
+
found += [p for p in siblings if _plist_field(p, "Label") == label]
|
|
575
|
+
return [p for p in dict.fromkeys(found) if not _same_file(p, plist_path)]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _answers(doc: Any, service_id: str, instance: str) -> bool:
|
|
579
|
+
"""Whether a well-known answer names this service and instance; any other JSON is no answer."""
|
|
580
|
+
service = doc.get("service") if isinstance(doc, dict) else None
|
|
581
|
+
return isinstance(service, dict) and service.get("id") == service_id and service.get("instance") == instance
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _refuse_held_label(label: str, plist_path: Path) -> None:
|
|
585
|
+
holders = _label_holders(label, plist_path)
|
|
586
|
+
if holders:
|
|
587
|
+
owner = _plist_field(holders[0], "UserName")
|
|
588
|
+
raise ServiceError("Label %s is already held by %s%s: the system domain is shared by every account of this "
|
|
589
|
+
"Mac, so a label is the machine's and one plist holds it. Choose a label of your own (for "
|
|
590
|
+
"example with the account in it), or remove that LaunchDaemon as root first."
|
|
591
|
+
% (label, holders[0], " (%s's)" % owner if owner else ""))
|
|
592
|
+
|
|
593
|
+
|
|
391
594
|
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
392
595
|
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
393
|
-
force_enable: bool = False) -> Dict[str, Any]:
|
|
596
|
+
force_enable: bool = False, domain: Optional[str] = None) -> Dict[str, Any]:
|
|
394
597
|
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
395
598
|
poll the well-known document until it answers with this identity.
|
|
396
599
|
|
|
@@ -398,38 +601,111 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
398
601
|
label. A label the operator disabled stays disabled and unloaded — the plist is still
|
|
399
602
|
rewritten, so the new release is what starts once they turn it back on — and the answer is
|
|
400
603
|
``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
|
|
401
|
-
``force_enable`` is for an operator who asked for the service to be switched on.
|
|
604
|
+
``force_enable`` is for an operator who asked for the service to be switched on.
|
|
605
|
+
|
|
606
|
+
A plist in ``/Library/LaunchDaemons`` (or ``domain="system"``) is a LaunchDaemon in launchd's system domain, started
|
|
607
|
+
at boot with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the
|
|
608
|
+
answer counts only when launchd's own job accepted the connection (its ``UserName``, not the installer, owns it).
|
|
609
|
+
A ``gui/<uid>`` bootstrap with no GUI session (SSH, a headless Mac after a FileVault restart) says so."""
|
|
402
610
|
plist_path = Path(plist_path)
|
|
611
|
+
dom = launchd_domain(plist_path, domain)
|
|
612
|
+
which = "system" if dom == "system" else None
|
|
613
|
+
_need_root(dom, label, plist_path, True)
|
|
614
|
+
if dom == "system":
|
|
615
|
+
try:
|
|
616
|
+
port = urllib.parse.urlsplit(origin).port
|
|
617
|
+
except ValueError: # a port out of range
|
|
618
|
+
port = None
|
|
619
|
+
if not (port and 1 <= port <= 65535 and origin == "http://127.0.0.1:%d" % port):
|
|
620
|
+
raise ServiceError("A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by "
|
|
621
|
+
"the job's pid and uid (FAA-278); %s is not." % origin)
|
|
622
|
+
try:
|
|
623
|
+
named = plistlib.loads(plist_bytes).get("UserName")
|
|
624
|
+
except Exception: # a plist that does not parse fails plutil -lint below
|
|
625
|
+
named = None
|
|
626
|
+
named = named if isinstance(named, str) else None # a UserName that is not a string names no account
|
|
627
|
+
if named:
|
|
628
|
+
_refuse_held_label(label, plist_path)
|
|
629
|
+
if named and plist_path.exists():
|
|
630
|
+
installed_label = _plist_field(plist_path, "Label")
|
|
631
|
+
if installed_label is not None and installed_label != label:
|
|
632
|
+
raise ServiceError("%s holds launchd label %s, not %s: writing over it would leave that job loaded from a "
|
|
633
|
+
"plist that no longer describes it. Uninstall it as root first, or write this one at "
|
|
634
|
+
"%s/%s.plist." % (plist_path, installed_label, label, SYSTEM_DAEMONS, label))
|
|
635
|
+
installed = _plist_field(plist_path, "UserName")
|
|
636
|
+
if installed and installed != named:
|
|
637
|
+
raise ServiceError("%s is %s's LaunchDaemon: the system domain is shared by every account of this Mac, so "
|
|
638
|
+
"a label is the machine's. Choose a label of your own (for example with the account in "
|
|
639
|
+
"it), or uninstall that one as root first." % (plist_path, installed))
|
|
640
|
+
if named and not _USER_NAME.match(named):
|
|
641
|
+
raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
|
|
642
|
+
% (named, _USER_NAME.pattern))
|
|
643
|
+
job_uid = _account_uid(named) if named else None
|
|
644
|
+
if named and job_uid is None:
|
|
645
|
+
raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
|
|
646
|
+
"whose Fabric and agents call the service." % named)
|
|
647
|
+
if job_uid == 0:
|
|
648
|
+
raise ServiceError("UserName %s is root: the job would run as root, keep its data in root's home, and every "
|
|
649
|
+
"client refuses root's listener (DEC-0154 HA-2). Name the account whose Fabric and agents "
|
|
650
|
+
"call the service." % named)
|
|
651
|
+
if not named:
|
|
652
|
+
raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
|
|
653
|
+
"home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
|
|
654
|
+
"launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
|
|
403
655
|
atomic_write(plist_path, plist_bytes, 0o644)
|
|
404
656
|
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
405
657
|
if lint.returncode != 0:
|
|
406
658
|
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
407
|
-
target = "%s/%s" % (
|
|
408
|
-
override = launchd_override(label)
|
|
659
|
+
target = "%s/%s" % (dom, label)
|
|
660
|
+
override = launchd_override(label, which)
|
|
409
661
|
if force_enable or override is None:
|
|
410
662
|
_launchctl("enable", target)
|
|
411
663
|
elif override == "disabled":
|
|
412
|
-
return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
|
|
664
|
+
return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label, which)}
|
|
413
665
|
_launchctl("bootout", target)
|
|
414
666
|
deadline = time.time() + 15
|
|
415
|
-
while launchd_loaded(label) and time.time() < deadline:
|
|
667
|
+
while launchd_loaded(label, which) and time.time() < deadline:
|
|
416
668
|
time.sleep(0.25)
|
|
417
669
|
last = ""
|
|
418
670
|
for _ in range(5):
|
|
419
|
-
result = _launchctl("bootstrap",
|
|
671
|
+
result = _launchctl("bootstrap", dom, str(plist_path))
|
|
420
672
|
if result.returncode == 0:
|
|
421
673
|
break
|
|
422
674
|
last = (result.stderr or result.stdout).strip()
|
|
423
675
|
time.sleep(1.0)
|
|
424
676
|
else:
|
|
677
|
+
if dom != "system" and re.search(r":\s*125:|Domain does not support|Could not find domain", last):
|
|
678
|
+
raise ServiceError("launchctl bootstrap failed: %s. This user has no GUI login session (an SSH session, or a "
|
|
679
|
+
"headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
|
|
680
|
+
"this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
|
|
681
|
+
"domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
|
|
425
682
|
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
426
683
|
deadline = time.time() + timeout
|
|
684
|
+
if dom == "system":
|
|
685
|
+
foreign = None
|
|
686
|
+
while time.time() < deadline:
|
|
687
|
+
job = launchd_pid(label, "system")
|
|
688
|
+
doc, why = _read_job_well_known(origin, job, job_uid) if job else (None, None)
|
|
689
|
+
foreign = why or foreign # the last refusal stays the reason while launchd shows no pid
|
|
690
|
+
if _answers(doc, service_id, instance):
|
|
691
|
+
return doc
|
|
692
|
+
time.sleep(0.5)
|
|
693
|
+
loaded = ("; the job stays loaded and its plist starts it again at boot: as root, `launchctl bootout system/%s` "
|
|
694
|
+
"and removing %s (or launchd_uninstall) take it away" % (label, plist_path))
|
|
695
|
+
if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
|
|
696
|
+
raise ServiceError("%s could not be proved this LaunchDaemon's: %s; the job's pid did not show the listener: "
|
|
697
|
+
"start the server with exec (no wrapper child), or read the job's log%s."
|
|
698
|
+
% (origin, foreign, loaded))
|
|
699
|
+
if foreign:
|
|
700
|
+
raise ServiceError("%s is not this LaunchDaemon's: %s%s." % (origin, foreign, loaded))
|
|
701
|
+
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log%s."
|
|
702
|
+
% (origin, service_id, instance, timeout, loaded))
|
|
427
703
|
foreign = vouch = None
|
|
428
704
|
while time.time() < deadline:
|
|
429
705
|
doc, foreign = _read_well_known(origin, vouch_pid=vouch)
|
|
430
706
|
if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
|
|
431
707
|
vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
|
|
432
|
-
if doc
|
|
708
|
+
if _answers(doc, service_id, instance):
|
|
433
709
|
return doc
|
|
434
710
|
time.sleep(0.5)
|
|
435
711
|
if foreign:
|
|
@@ -444,20 +720,31 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
444
720
|
|
|
445
721
|
|
|
446
722
|
def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
|
|
447
|
-
service_id: Optional[str] = None, instance: str = "default"
|
|
723
|
+
service_id: Optional[str] = None, instance: str = "default",
|
|
724
|
+
domain: Optional[str] = None) -> Dict[str, Any]:
|
|
448
725
|
"""Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
|
|
449
726
|
the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
|
|
450
727
|
override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
|
|
451
728
|
unless ``purge`` (then ``service_id`` names whose data, logs and cache go, and ``instance`` which
|
|
452
729
|
instance's: ``instance_dirs``, so one context's purge never reaches another's, FAA-125). A job still
|
|
453
|
-
loaded at ``timeout`` raises, leaving the plist and data in place.
|
|
730
|
+
loaded at ``timeout`` raises, leaving the plist and data in place. A LaunchDaemon (a plist in
|
|
731
|
+
``/Library/LaunchDaemons``, or ``domain="system"``) is removed only by root, and its data belongs to its UserName,
|
|
732
|
+
so ``purge`` is refused for it: remove the data as that user (FAA-278)."""
|
|
454
733
|
if purge and not service_id:
|
|
455
734
|
raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
|
|
735
|
+
dom = launchd_domain(Path(plist_path), domain)
|
|
736
|
+
which = "system" if dom == "system" else None
|
|
737
|
+
_need_root(dom, label, Path(plist_path), False)
|
|
738
|
+
if purge and dom == "system":
|
|
739
|
+
raise ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then "
|
|
740
|
+
"remove the data as that user.")
|
|
741
|
+
if dom == "system":
|
|
742
|
+
_refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
|
|
456
743
|
dirs = instance_dirs(str(service_id), instance) if purge else {}
|
|
457
|
-
target = "%s/%s" % (
|
|
744
|
+
target = "%s/%s" % (dom, label)
|
|
458
745
|
_launchctl("bootout", target)
|
|
459
746
|
deadline = time.monotonic() + timeout
|
|
460
|
-
while launchd_loaded(label):
|
|
747
|
+
while launchd_loaded(label, which):
|
|
461
748
|
if time.monotonic() >= deadline:
|
|
462
749
|
raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
|
|
463
750
|
time.sleep(0.1)
|