@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.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 +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
CHANGED
|
@@ -10,8 +10,11 @@ U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import bisect
|
|
14
|
+
import ipaddress
|
|
13
15
|
import re
|
|
14
|
-
from typing import Dict, List, Tuple
|
|
16
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
import unicodedata
|
|
15
18
|
import urllib.parse
|
|
16
19
|
|
|
17
20
|
CREDENTIAL_WORDS = (
|
|
@@ -30,8 +33,48 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
|
30
33
|
JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
31
34
|
_NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
|
|
32
35
|
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
_URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def jwt_spans(text: str, first: bool = False) -> List[Tuple[int, int]]:
|
|
40
|
+
"""The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's `jwtSpans`,
|
|
41
|
+
FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of
|
|
42
|
+
one run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends.
|
|
43
|
+
`first` stops at the first match."""
|
|
44
|
+
spans: List[Tuple[int, int]] = []
|
|
45
|
+
at = text.find("eyJ")
|
|
46
|
+
while at >= 0:
|
|
47
|
+
one = _URL_BASE64_RUN.match(text, at + 3).end()
|
|
48
|
+
resume = one
|
|
49
|
+
if one - at - 3 >= 2 and text[one:one + 1] == ".":
|
|
50
|
+
two = _URL_BASE64_RUN.match(text, one + 1).end()
|
|
51
|
+
if two - one - 1 >= 2 and text[two:two + 1] == ".":
|
|
52
|
+
three = _URL_BASE64_RUN.match(text, two + 1).end()
|
|
53
|
+
spans.append((at, three))
|
|
54
|
+
if first:
|
|
55
|
+
break
|
|
56
|
+
resume = three
|
|
57
|
+
at = text.find("eyJ", resume)
|
|
58
|
+
return spans
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_jwt(text: str) -> bool:
|
|
62
|
+
"""Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's `hasJwt`, FAA-313)."""
|
|
63
|
+
return bool(jwt_spans(text, True))
|
|
64
|
+
#: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
|
|
65
|
+
#: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
|
|
66
|
+
#: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
|
|
67
|
+
_KEY_BOUNDARY = (r"(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt\"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}"
|
|
68
|
+
r"|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)")
|
|
69
|
+
#: The provider keys' shapes, prefixes in their canonical case only (DEC-0151 item 7: `SK-` or `GHP_` is no key). An
|
|
70
|
+
#: `sk-` key holds, within 256 characters, a digit and an upper-case letter with 16 or more characters, or a digit with
|
|
71
|
+
#: 32 or more; the look-aheads are bounded to 255 characters and lazy, so a long run of prefixes reads in linear time.
|
|
72
|
+
_PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
|
|
73
|
+
r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})")
|
|
74
|
+
#: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
|
|
75
|
+
#: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
|
|
76
|
+
#: read the matched text or its index.
|
|
77
|
+
SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
|
|
35
78
|
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
36
79
|
_BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
|
|
37
80
|
re.IGNORECASE | re.ASCII)
|
|
@@ -71,7 +114,7 @@ def _long_secret(value: str) -> bool:
|
|
|
71
114
|
|
|
72
115
|
def secret_value(value: str) -> bool:
|
|
73
116
|
"""A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
|
|
74
|
-
return bool(
|
|
117
|
+
return bool(has_jwt(value) or SECRET_VALUE.search(value) or _long_secret(value))
|
|
75
118
|
|
|
76
119
|
|
|
77
120
|
def _path_like(piece: str) -> bool:
|
|
@@ -83,7 +126,7 @@ def _path_like(piece: str) -> bool:
|
|
|
83
126
|
def secret_argument(value: str) -> bool:
|
|
84
127
|
"""The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
|
|
85
128
|
so a folder path holding a session id is not taken for a key."""
|
|
86
|
-
if
|
|
129
|
+
if has_jwt(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
|
|
87
130
|
return True
|
|
88
131
|
for piece in _ARGUMENT_SPLIT.split(value):
|
|
89
132
|
if _path_like(piece):
|
|
@@ -129,6 +172,155 @@ _C0_OR_SPACE = "".join(map(chr, range(0x21)))
|
|
|
129
172
|
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
130
173
|
|
|
131
174
|
|
|
175
|
+
# The WHATWG host parser, enough to know when `new URL` refuses a host (FAA-98, moved here from fabric_connections for
|
|
176
|
+
# secret_url in FAA-309, so the module still ships alone in adapting-projects-to-fabric).
|
|
177
|
+
_FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
178
|
+
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
|
|
182
|
+
# the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
|
|
183
|
+
# test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
|
|
184
|
+
# code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
|
|
185
|
+
# classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
|
|
186
|
+
# Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
|
|
187
|
+
_IDNA_DISALLOWED = (
|
|
188
|
+
(0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
|
|
189
|
+
(0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
|
|
190
|
+
(0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
|
|
191
|
+
(0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
|
|
192
|
+
(0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
|
|
193
|
+
(0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
|
|
194
|
+
(0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
|
|
195
|
+
(0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
|
|
196
|
+
(0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
|
|
197
|
+
(0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
|
|
198
|
+
(0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
|
|
199
|
+
(0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
|
|
200
|
+
(0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
|
|
201
|
+
(0x100000, 0x10fffd),
|
|
202
|
+
)
|
|
203
|
+
_IDNA_IGNORED = (
|
|
204
|
+
(0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
|
|
205
|
+
(0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
|
|
206
|
+
)
|
|
207
|
+
_IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
|
|
208
|
+
_U32 = unicodedata.ucd_3_2_0
|
|
209
|
+
_RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
|
|
210
|
+
_JOINERS = "\u200c\u200d"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
|
|
214
|
+
i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
|
|
215
|
+
return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _known(c: str) -> bool:
|
|
219
|
+
return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _joiner_refused(label: str, i: int) -> bool:
|
|
223
|
+
"""CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
|
|
224
|
+
(`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
|
|
225
|
+
in its label has none for certain."""
|
|
226
|
+
return all(c.isascii() for j, c in enumerate(label) if j != i)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _bidi_refused(label: str) -> bool:
|
|
230
|
+
"""RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
|
|
231
|
+
not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
|
|
232
|
+
if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
|
|
233
|
+
return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
|
|
234
|
+
k = [_U32.bidirectional(c) for c in label]
|
|
235
|
+
if not any(x in ("R", "AL", "AN") for x in k):
|
|
236
|
+
return False
|
|
237
|
+
body = list(k)
|
|
238
|
+
while body and body[-1] == "NSM":
|
|
239
|
+
body.pop()
|
|
240
|
+
if not body or body[-1] not in ("R", "AL", "EN", "AN"):
|
|
241
|
+
return True
|
|
242
|
+
return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _idna_host(host: str) -> Optional[str]:
|
|
246
|
+
"""None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
|
|
247
|
+
as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
|
|
248
|
+
if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
|
|
249
|
+
return None
|
|
250
|
+
kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
|
|
251
|
+
if kept == "":
|
|
252
|
+
return None # nothing left: an empty host
|
|
253
|
+
if not all(_known(c) for c in kept):
|
|
254
|
+
return host # a code point this Python's Unicode does not know: the contextual rules are not read
|
|
255
|
+
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
|
|
256
|
+
for label in _IDNA_DOTS.split(mapped):
|
|
257
|
+
first = label[:1]
|
|
258
|
+
if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
|
|
259
|
+
return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
|
|
260
|
+
if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
|
|
261
|
+
return None
|
|
262
|
+
if _bidi_refused(label):
|
|
263
|
+
return None
|
|
264
|
+
return _IDNA_DOTS.sub(".", mapped)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _ipv4_number(part: str) -> Optional[int]:
|
|
268
|
+
if part == "":
|
|
269
|
+
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
270
|
+
base = 10
|
|
271
|
+
if part[:2].lower() == "0x":
|
|
272
|
+
part, base = part[2:], 16
|
|
273
|
+
elif len(part) > 1 and part[0] == "0":
|
|
274
|
+
part, base = part[1:], 8
|
|
275
|
+
if part == "":
|
|
276
|
+
return 0
|
|
277
|
+
digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
|
|
278
|
+
return int(part, base) if all(c in digits for c in part) else None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _domain_ok(host: str) -> bool:
|
|
282
|
+
"""The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
|
|
283
|
+
if host.startswith("["):
|
|
284
|
+
return host.endswith("]") and _ipv6_ok(host[1:-1])
|
|
285
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
|
|
286
|
+
try:
|
|
287
|
+
host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
|
|
288
|
+
except UnicodeDecodeError:
|
|
289
|
+
return False
|
|
290
|
+
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
291
|
+
return False
|
|
292
|
+
# A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
|
|
293
|
+
# the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
294
|
+
# (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
|
|
295
|
+
if any(ord(c) > 0x7f for c in host):
|
|
296
|
+
mapped = _idna_host(host)
|
|
297
|
+
if mapped is None:
|
|
298
|
+
return False
|
|
299
|
+
if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
|
|
300
|
+
return True
|
|
301
|
+
host = mapped
|
|
302
|
+
labels = host.split(".")
|
|
303
|
+
if labels and labels[-1] == "" and len(labels) > 1:
|
|
304
|
+
labels = labels[:-1]
|
|
305
|
+
last = labels[-1] if labels else ""
|
|
306
|
+
if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
|
|
307
|
+
return host != ""
|
|
308
|
+
numbers = [_ipv4_number(label) for label in labels]
|
|
309
|
+
if len(numbers) > 4 or any(n is None for n in numbers):
|
|
310
|
+
return False
|
|
311
|
+
return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ipv6_ok(text: str) -> bool:
|
|
315
|
+
if "%" in text:
|
|
316
|
+
return False # a zone id: `ipaddress` takes one, WHATWG does not
|
|
317
|
+
try:
|
|
318
|
+
ipaddress.IPv6Address(text)
|
|
319
|
+
return True
|
|
320
|
+
except ValueError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
132
324
|
def _percent_encode(text: str, also: str) -> str:
|
|
133
325
|
"""WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
|
|
134
326
|
above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
|
|
@@ -150,8 +342,8 @@ def url_parts(value: str) -> Tuple[str, str, str]:
|
|
|
150
342
|
after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
|
|
151
343
|
next slash, backslash, `?` or `#`, so `<https>:u:p@h` and `<https>:\\u:p@h` carry the password p; any other scheme
|
|
152
344
|
has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
|
|
153
|
-
last `@`, and the password follows its first `:`.
|
|
154
|
-
|
|
345
|
+
last `@`, and the password follows its first `:`. The host is not checked here: secret_url refuses first a URL
|
|
346
|
+
whose host or port `new URL` refuses (url_refused, FAA-309)."""
|
|
155
347
|
scheme, _, rest = value.partition(":")
|
|
156
348
|
rest, _, fragment = rest.partition("#")
|
|
157
349
|
rest, _, query = rest.partition("?")
|
|
@@ -170,10 +362,60 @@ def url_parts(value: str) -> Tuple[str, str, str]:
|
|
|
170
362
|
_url_parts = url_parts # the name before FAA-250, kept for code that reached it
|
|
171
363
|
|
|
172
364
|
|
|
365
|
+
def url_refused(value: str) -> bool:
|
|
366
|
+
"""Whether `new URL` refuses an absolute URL (trimmed as secret_url trims it) for its host or port (FAA-309), read
|
|
367
|
+
with the same authority url_parts reads: a special scheme's host through the WHATWG host parser and the IDNA reading
|
|
368
|
+
of FAA-98 (`_domain_ok`), never empty; `file`'s host, after `//`, the same, empty allowed; another scheme's host,
|
|
369
|
+
after `//`, without a forbidden host code point, and empty only with no user information and no `:` after it (an
|
|
370
|
+
empty port is refused too); a port of ASCII digits up to 65535. Where the reading is unsure, the host is taken as
|
|
371
|
+
valid (the detector's safe side)."""
|
|
372
|
+
value = _LONE_SURROGATE.sub("\ufffd", value)
|
|
373
|
+
scheme, _, rest = value.partition(":")
|
|
374
|
+
rest = rest.partition("#")[0].partition("?")[0]
|
|
375
|
+
lowered = scheme.lower()
|
|
376
|
+
if lowered in _SPECIAL:
|
|
377
|
+
authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
|
|
378
|
+
elif lowered == "file":
|
|
379
|
+
rest = rest.replace("\\", "/")
|
|
380
|
+
if not rest.startswith("//"):
|
|
381
|
+
return False
|
|
382
|
+
host = rest[2:].partition("/")[0]
|
|
383
|
+
return not (host == "" or re.fullmatch(r"[A-Za-z][:|]", host) or _domain_ok(host))
|
|
384
|
+
elif rest.startswith("//"):
|
|
385
|
+
authority = rest[2:].partition("/")[0]
|
|
386
|
+
else:
|
|
387
|
+
return False
|
|
388
|
+
hostport = authority.rpartition("@")[2]
|
|
389
|
+
if hostport.startswith("["):
|
|
390
|
+
close = hostport.find("]")
|
|
391
|
+
if close < 0:
|
|
392
|
+
return True
|
|
393
|
+
host, after = hostport[:close + 1], hostport[close + 1:]
|
|
394
|
+
if after and not after.startswith(":"):
|
|
395
|
+
return True
|
|
396
|
+
port = after[1:]
|
|
397
|
+
else:
|
|
398
|
+
host, _, port = hostport.partition(":")
|
|
399
|
+
if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
|
|
400
|
+
return True
|
|
401
|
+
if lowered in _SPECIAL:
|
|
402
|
+
return host == "" or not _domain_ok(host)
|
|
403
|
+
if host.startswith("["):
|
|
404
|
+
return not _ipv6_ok(host[1:-1])
|
|
405
|
+
return any(c in _FORBIDDEN_HOST for c in host) or (host == "" and ("@" in authority or ":" in hostport))
|
|
406
|
+
|
|
407
|
+
|
|
173
408
|
def secret_url(value: str) -> bool:
|
|
174
409
|
"""Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
|
|
175
410
|
password in its user information, or a query or fragment parameter whose name can only carry a credential and has
|
|
176
|
-
a value, or whose value is shaped like one. A string that is not an absolute URL
|
|
411
|
+
a value, or whose value is shaped like one. A string that is not an absolute URL, or one whose host or port
|
|
412
|
+
`new URL` refuses (url_refused, FAA-309), is checked as an argument only, as the Node twin's secretUrl reads it."""
|
|
413
|
+
return _url_credential(value, hosts=True)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _url_credential(value: str, hosts: bool) -> bool:
|
|
417
|
+
"""secret_url; with `hosts` False the host is not checked, as repository_credential reads a repository's parameters
|
|
418
|
+
whatever its host (the Node twin's repositoryParts)."""
|
|
177
419
|
if secret_argument(value):
|
|
178
420
|
return True
|
|
179
421
|
# What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
|
|
@@ -181,6 +423,8 @@ def secret_url(value: str) -> bool:
|
|
|
181
423
|
value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
|
|
182
424
|
if not _SCHEME.match(value):
|
|
183
425
|
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
426
|
+
if hosts and url_refused(value):
|
|
427
|
+
return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
|
|
184
428
|
password, query, fragment = url_parts(value)
|
|
185
429
|
if password:
|
|
186
430
|
return True
|
|
@@ -194,7 +438,10 @@ def secret_url(value: str) -> bool:
|
|
|
194
438
|
_REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
|
|
195
439
|
_REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
|
|
196
440
|
_REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
197
|
-
|
|
441
|
+
#: A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
|
|
442
|
+
#: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
|
|
443
|
+
_REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
|
|
444
|
+
_KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
|
|
198
445
|
_JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
|
|
199
446
|
|
|
200
447
|
|
|
@@ -245,4 +492,4 @@ def repository_credential(value: str) -> bool:
|
|
|
245
492
|
odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
|
|
246
493
|
if piece and not repository_name(piece)]
|
|
247
494
|
# the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
|
|
248
|
-
return
|
|
495
|
+
return _url_credential(head + tail, hosts=False) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -88,8 +88,11 @@ from fabric_lock import ( # noqa: F401
|
|
|
88
88
|
LOCK_WAIT_SUPERVISED_SECONDS,
|
|
89
89
|
LOCK_BACKOFF_FIRST_SECONDS,
|
|
90
90
|
LOCK_BACKOFF_MAX_SECONDS,
|
|
91
|
+
LOCK_WATCH_SECONDS,
|
|
91
92
|
AlreadyRunning,
|
|
92
93
|
InstanceLock,
|
|
94
|
+
lost_lock_message,
|
|
95
|
+
stop_lost_holder,
|
|
93
96
|
lock_name,
|
|
94
97
|
_hold_lock_name,
|
|
95
98
|
_drop_lock_name,
|
|
@@ -228,6 +231,8 @@ from fabric_descriptor import ( # noqa: F401
|
|
|
228
231
|
_NOT_LAUNCHD,
|
|
229
232
|
_AUTH_HEADER,
|
|
230
233
|
LoopbackHTTPServer,
|
|
234
|
+
PEER_CHECKS,
|
|
235
|
+
connection_refusal,
|
|
231
236
|
descriptor_path,
|
|
232
237
|
read_descriptors,
|
|
233
238
|
validate_descriptor,
|
|
@@ -336,6 +341,7 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
336
341
|
_launchctl,
|
|
337
342
|
_domain,
|
|
338
343
|
SYSTEM_DAEMONS,
|
|
344
|
+
USER_NAME_RULE,
|
|
339
345
|
launchd_domain,
|
|
340
346
|
launchd_loaded,
|
|
341
347
|
launchd_pid,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -47,6 +47,8 @@ DEFAULT_EXIT_TIMEOUT = 15
|
|
|
47
47
|
|
|
48
48
|
_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
|
|
49
49
|
_USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
|
|
50
|
+
#: The short-name rule in words, printed alike by both twins (FAA-286 item 13).
|
|
51
|
+
USER_NAME_RULE = "a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -"
|
|
50
52
|
|
|
51
53
|
#: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
|
|
52
54
|
SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
|
|
@@ -373,6 +375,9 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
|
|
|
373
375
|
session; a ``gui/<uid>`` job starts only while its user is logged in."""
|
|
374
376
|
if domain not in (None, "gui", "system"):
|
|
375
377
|
raise ServiceError("domain must be gui or system")
|
|
378
|
+
if plist_path is not None and re.match(r"^(?:system|gui)(?:/[0-9]*)?\Z", str(plist_path)):
|
|
379
|
+
raise ServiceError("%s is a launchd domain, not a plist path: pass domain=%r (FAA-286)"
|
|
380
|
+
% (plist_path, "system" if str(plist_path) == "system" else "gui"))
|
|
376
381
|
if domain is None and plist_path is not None:
|
|
377
382
|
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
|
|
378
383
|
domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
|
|
@@ -384,6 +389,39 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
|
|
|
384
389
|
return _domain()
|
|
385
390
|
|
|
386
391
|
|
|
392
|
+
def _in_daemons(plist_path: Path) -> bool:
|
|
393
|
+
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
|
|
394
|
+
return folder.lower() == str(SYSTEM_DAEMONS).lower()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _pair_problem(plist_path: Path, domain: Optional[str]) -> Optional[str]:
|
|
398
|
+
"""A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd
|
|
399
|
+
also loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root).
|
|
400
|
+
Another folder is allowed for a system job, as the tests' and a staging install's are."""
|
|
401
|
+
if domain == "system" and Path(os.path.abspath(str(plist_path))).parent.name.lower() == "launchagents":
|
|
402
|
+
return ("A LaunchDaemon's plist lives in %s: %s is in a LaunchAgents folder, which launchd also loads into "
|
|
403
|
+
"gui/<uid> at login, so the job would run twice." % (SYSTEM_DAEMONS, plist_path))
|
|
404
|
+
if domain == "gui" and _in_daemons(plist_path):
|
|
405
|
+
return ("%s is in %s, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in "
|
|
406
|
+
"~/Library/LaunchAgents." % (plist_path, SYSTEM_DAEMONS))
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _leftover_agent(label: str, user_name: str) -> Optional[Path]:
|
|
411
|
+
"""A LaunchAgent of ``label`` in ``user_name``'s ~/Library/LaunchAgents, which would run a second copy at its login
|
|
412
|
+
(FAA-286 item 6)."""
|
|
413
|
+
try:
|
|
414
|
+
import pwd
|
|
415
|
+
home = Path(pwd.getpwnam(user_name).pw_dir)
|
|
416
|
+
except (ImportError, KeyError, ValueError):
|
|
417
|
+
return None
|
|
418
|
+
try:
|
|
419
|
+
agents = sorted((home / "Library" / "LaunchAgents").glob("*.plist"))
|
|
420
|
+
except OSError:
|
|
421
|
+
return None
|
|
422
|
+
return next((p for p in agents if _plist_field(p, "Label") == label), None)
|
|
423
|
+
|
|
424
|
+
|
|
387
425
|
def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
|
|
388
426
|
"""A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
|
|
389
427
|
if domain != "system" or os.geteuid() == 0:
|
|
@@ -700,6 +738,9 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
700
738
|
read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
|
|
701
739
|
read_job = fetch_job_well_known or job_well_known_of
|
|
702
740
|
plist_path = Path(plist_path)
|
|
741
|
+
pair = _pair_problem(plist_path, domain)
|
|
742
|
+
if pair:
|
|
743
|
+
raise ServiceError(pair)
|
|
703
744
|
dom = launchd_domain(plist_path, domain)
|
|
704
745
|
which = "system" if dom == "system" else None
|
|
705
746
|
_need_root(dom, label, plist_path, True)
|
|
@@ -713,8 +754,8 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
713
754
|
"the job's pid and uid (FAA-278); %s is not." % origin)
|
|
714
755
|
try:
|
|
715
756
|
named = plistlib.loads(plist_bytes).get("UserName")
|
|
716
|
-
except Exception: #
|
|
717
|
-
|
|
757
|
+
except Exception as exc: # named as what it is, never as a missing UserName (FAA-286 item 16)
|
|
758
|
+
raise ServiceError("The plist does not parse (%s): write it with launchd_plist." % type(exc).__name__)
|
|
718
759
|
named = named if isinstance(named, str) else None # a UserName that is not a string names no account
|
|
719
760
|
if named:
|
|
720
761
|
_refuse_held_label(label, plist_path)
|
|
@@ -731,7 +772,7 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
731
772
|
"it), or uninstall that one as root first." % (plist_path, installed))
|
|
732
773
|
if named and not _USER_NAME.match(named):
|
|
733
774
|
raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
|
|
734
|
-
% (named,
|
|
775
|
+
% (named, USER_NAME_RULE))
|
|
735
776
|
job_uid = _account_uid(named) if named else None
|
|
736
777
|
if named and job_uid is None:
|
|
737
778
|
raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
|
|
@@ -744,6 +785,12 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
744
785
|
raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
|
|
745
786
|
"home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
|
|
746
787
|
"launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
|
|
788
|
+
leftover = _leftover_agent(label, named)
|
|
789
|
+
if leftover is not None:
|
|
790
|
+
raise ServiceError("%s is a LaunchAgent of label %s: it would start a second copy at %s's login beside this "
|
|
791
|
+
"LaunchDaemon. Remove it first (launchd_uninstall as %s; lifecycle reference, The system "
|
|
792
|
+
"domain)." % (leftover, label, named, named))
|
|
793
|
+
existed = plist_path.exists()
|
|
747
794
|
atomic_write(plist_path, plist_bytes, 0o644)
|
|
748
795
|
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
749
796
|
if lint.returncode != 0:
|
|
@@ -771,6 +818,16 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
771
818
|
"headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
|
|
772
819
|
"this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
|
|
773
820
|
"domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
|
|
821
|
+
if dom == "system":
|
|
822
|
+
# a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
|
|
823
|
+
if not existed:
|
|
824
|
+
try:
|
|
825
|
+
plist_path.unlink()
|
|
826
|
+
except OSError:
|
|
827
|
+
pass
|
|
828
|
+
raise ServiceError("launchctl bootstrap failed: %s; %s" % (last, "the plist this call wrote was removed"
|
|
829
|
+
if not plist_path.exists() else "%s stays and loads at the next boot: remove it as root"
|
|
830
|
+
% plist_path))
|
|
774
831
|
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
775
832
|
deadline = time.time() + timeout
|
|
776
833
|
if dom == "system":
|
|
@@ -834,6 +891,10 @@ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, pu
|
|
|
834
891
|
"remove the data as that user.")
|
|
835
892
|
if dom == "system":
|
|
836
893
|
_refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
|
|
894
|
+
holds = _plist_field(Path(plist_path), "Label") if Path(plist_path).exists() else None
|
|
895
|
+
if holds is not None and holds != label: # FAA-286 item 9: booting out one job and unlinking another's plist
|
|
896
|
+
raise ServiceError("%s holds launchd label %s, not %s: nothing was removed. Name the plist of %s, or uninstall %s "
|
|
897
|
+
"with its own label." % (plist_path, holds, label, label, holds))
|
|
837
898
|
dirs = instance_dirs(str(service_id), instance) if purge else {}
|
|
838
899
|
target = "%s/%s" % (dom, label)
|
|
839
900
|
_launchctl("bootout", target)
|