@passioncode-ai/fabric-agent-adapter 0.77.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 +85 -0
- package/README.md +17 -4
- package/SECURITY.md +17 -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 +10 -2
- 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 +27 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
- 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-service.mjs +4 -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_service.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +308 -22
- 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 +113 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -2
- 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 +22 -32
- 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/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-tailnet.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). The twin of
|
|
2
|
+
// fabric_tailnet.py; both answer test/fixtures/tailnet/vectors.json alike. No dependencies beyond node:crypto and
|
|
3
|
+
// node:net.
|
|
4
|
+
//
|
|
5
|
+
// The header `Tailscale-User-Login` is believed only on the agent's own serve listener (a loopback port or a Unix
|
|
6
|
+
// socket that only serve reaches) and only from a loopback peer; on any other listener a request carrying it is
|
|
7
|
+
// refused. What a person may open comes from a `tailnet-perimeter/1` file generated from the organization's access
|
|
8
|
+
// registry (it names its source in `generatedFrom`); MCP also takes a personal key, stored as its SHA-256, owned by one
|
|
9
|
+
// login and no wider than that login's groups. Refusals are 401 or 403 with a stable `code`.
|
|
10
|
+
|
|
11
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
|
|
14
|
+
export const PERIMETER_SCHEMA = 'tailnet-perimeter/1';
|
|
15
|
+
export const IDENTITY_HEADER = 'tailscale-user-login';
|
|
16
|
+
export const SERVE_HEADERS = Object.freeze(['tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic']);
|
|
17
|
+
export const LISTENER_SERVE = 'serve';
|
|
18
|
+
export const SURFACES = Object.freeze(['dashboard', 'mcp']);
|
|
19
|
+
|
|
20
|
+
const LOGIN = /^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$/;
|
|
21
|
+
const NAME = /^[a-z][a-z0-9._:/-]{0,127}$/;
|
|
22
|
+
const KEY_HASH = /^sha256:[0-9a-f]{64}$/;
|
|
23
|
+
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
24
|
+
|
|
25
|
+
export const REFUSALS = Object.freeze({
|
|
26
|
+
'serve-header-off-serve': 403,
|
|
27
|
+
'peer-not-loopback': 403,
|
|
28
|
+
'identity-missing': 401,
|
|
29
|
+
'identity-ambiguous': 401,
|
|
30
|
+
'identity-malformed': 401,
|
|
31
|
+
'not-in-perimeter': 403,
|
|
32
|
+
'dashboard-not-granted': 403,
|
|
33
|
+
'capability-not-granted': 403,
|
|
34
|
+
'key-required': 401,
|
|
35
|
+
'key-unknown': 401,
|
|
36
|
+
'key-not-yours': 403,
|
|
37
|
+
'key-wider-than-group': 403,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A perimeter or key list that cannot be used: `code` names why, `where` the place, never a value. */
|
|
41
|
+
export class PerimeterError extends Error {
|
|
42
|
+
constructor(code, where = '') {
|
|
43
|
+
super(`${code}${where ? ` at ${where}` : ''}`);
|
|
44
|
+
this.name = 'PerimeterError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.where = where;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The stored form of a personal key: `sha256:<hex>` of its UTF-8 bytes. */
|
|
51
|
+
export function hashKey(value) {
|
|
52
|
+
if (typeof value !== 'string' || !value) throw new TypeError('a key is a non-empty string');
|
|
53
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sortedSet = (values) => [...new Set(values)].sort();
|
|
57
|
+
const isRecordValue = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
const pyKeySort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
59
|
+
|
|
60
|
+
function names(value, where) {
|
|
61
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && NAME.test(v))) {
|
|
62
|
+
throw new PerimeterError('perimeter-invalid', where);
|
|
63
|
+
}
|
|
64
|
+
return sortedSet(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check a parsed `tailnet-perimeter/1` document and answer its normalized form; throw PerimeterError otherwise. */
|
|
68
|
+
export function loadPerimeter(doc) {
|
|
69
|
+
if (!isRecordValue(doc) || doc.schema !== PERIMETER_SCHEMA) throw new PerimeterError('perimeter-schema', 'schema');
|
|
70
|
+
const source = doc.generatedFrom;
|
|
71
|
+
if (!isRecordValue(source) || !['registry', 'revision', 'at'].every((k) => typeof source[k] === 'string' && source[k])) {
|
|
72
|
+
throw new PerimeterError('perimeter-not-generated', 'generatedFrom');
|
|
73
|
+
}
|
|
74
|
+
const groups = doc.groups;
|
|
75
|
+
if (!isRecordValue(groups) || Object.keys(groups).length === 0) throw new PerimeterError('perimeter-invalid', 'groups');
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const name of Object.keys(groups).sort(pyKeySort)) {
|
|
78
|
+
const group = groups[name];
|
|
79
|
+
const where = NAME.test(name) ? `groups.${name}` : 'groups';
|
|
80
|
+
if (!NAME.test(name) || !isRecordValue(group)) throw new PerimeterError('perimeter-invalid', where);
|
|
81
|
+
const members = group.members;
|
|
82
|
+
if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
|
|
83
|
+
throw new PerimeterError('perimeter-invalid', `${where}.members`);
|
|
84
|
+
}
|
|
85
|
+
out[name] = {
|
|
86
|
+
members: sortedSet(members.map((m) => m.toLowerCase())),
|
|
87
|
+
dashboards: names(group.dashboards ?? [], `${where}.dashboards`),
|
|
88
|
+
capabilities: names(group.capabilities ?? [], `${where}.capabilities`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schema: PERIMETER_SCHEMA,
|
|
93
|
+
generatedFrom: { registry: source.registry, revision: source.revision, at: source.at },
|
|
94
|
+
groups: out,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Check a list of personal keys `{id, login, sha256, capabilities}` (the hash only, never the key). */
|
|
99
|
+
export function loadKeys(keys) {
|
|
100
|
+
if (!Array.isArray(keys)) throw new PerimeterError('keys-invalid', 'keys');
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
keys.forEach((key, i) => {
|
|
104
|
+
const where = `keys[${i}]`;
|
|
105
|
+
if (!isRecordValue(key) || typeof key.id !== 'string' || !KEY_ID.test(key.id)
|
|
106
|
+
|| typeof key.login !== 'string' || !LOGIN.test(key.login)
|
|
107
|
+
|| typeof key.sha256 !== 'string' || !KEY_HASH.test(key.sha256)) {
|
|
108
|
+
throw new PerimeterError('keys-invalid', where);
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(key.id) || out.some((k) => k.sha256 === key.sha256)) throw new PerimeterError('keys-duplicate', where);
|
|
111
|
+
seen.add(key.id);
|
|
112
|
+
out.push({ id: key.id, login: key.login.toLowerCase(), sha256: key.sha256,
|
|
113
|
+
capabilities: names(key.capabilities ?? [], `${where}.capabilities`) });
|
|
114
|
+
});
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function headerValues(headers, name) {
|
|
119
|
+
// A list of pairs, a Map or a fetch Headers (anything with entries()), or a plain object such as Node's req.headers.
|
|
120
|
+
const plain = headers === null || headers === undefined || Object.getPrototypeOf(headers) === Object.prototype
|
|
121
|
+
|| Object.getPrototypeOf(headers) === null;
|
|
122
|
+
const pairs = Array.isArray(headers) ? headers
|
|
123
|
+
: !plain && typeof headers.entries === 'function' ? [...headers.entries()]
|
|
124
|
+
: Object.entries(headers ?? {});
|
|
125
|
+
const found = [];
|
|
126
|
+
for (const [key, value] of pairs) {
|
|
127
|
+
if (typeof key === 'string' && key.toLowerCase() === name) found.push(...(Array.isArray(value) ? value : [value]));
|
|
128
|
+
}
|
|
129
|
+
return found.map((v) => (typeof v === 'string' ? v : ''));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isLoopback(address) {
|
|
133
|
+
if (address === 'unix') return true;
|
|
134
|
+
if (typeof address !== 'string') return false;
|
|
135
|
+
const ip = address.split('%', 1)[0];
|
|
136
|
+
const family = isIP(ip);
|
|
137
|
+
if (family === 4) return ip.split('.')[0] === '127';
|
|
138
|
+
if (family !== 6) return false;
|
|
139
|
+
const host = new URL(`http://[${ip}]/`).hostname; // the canonical compressed form: [::1], [::ffff:7f00:1]
|
|
140
|
+
return host === '[::1]' || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const refuse = (code, login = null, groups = []) => ({ allowed: false, status: REFUSALS[code], code, login, groups });
|
|
144
|
+
|
|
145
|
+
function bearer(headers) {
|
|
146
|
+
const values = headerValues(headers, 'authorization');
|
|
147
|
+
if (values.length !== 1) return null;
|
|
148
|
+
const text = values[0].trim();
|
|
149
|
+
const space = text.indexOf(' ');
|
|
150
|
+
if (space < 0) return null;
|
|
151
|
+
const scheme = text.slice(0, space);
|
|
152
|
+
const rest = text.slice(space + 1).trim();
|
|
153
|
+
return scheme.toLowerCase() === 'bearer' && rest && !rest.includes(' ') ? rest : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameDigest(a, b) {
|
|
157
|
+
const x = Buffer.from(a, 'utf8');
|
|
158
|
+
const y = Buffer.from(b, 'utf8');
|
|
159
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide one request. `listener` is which of the agent's listeners took it (`serve` for serve's own); `peer` the
|
|
164
|
+
* connecting address (`unix` for a Unix socket); `perimeter` and `keys` as loadPerimeter and loadKeys answer;
|
|
165
|
+
* `surface` `dashboard` or `mcp`; `target` the dashboard's or the capability's name. Answers
|
|
166
|
+
* {allowed, status, code, login, groups}: status 200 with code `ok`, else 401 or 403 with the refusal's code.
|
|
167
|
+
*/
|
|
168
|
+
export function authorize({ listener, peer, headers, perimeter, surface, target, keys = null }) {
|
|
169
|
+
if (!SURFACES.includes(surface)) throw new TypeError(`surface is one of ${SURFACES.join(', ')}`);
|
|
170
|
+
if (listener !== LISTENER_SERVE) {
|
|
171
|
+
if (SERVE_HEADERS.some((h) => headerValues(headers, h).length)) return refuse('serve-header-off-serve');
|
|
172
|
+
throw new TypeError("authorize decides serve's listener; the Fabric listener keeps its session key");
|
|
173
|
+
}
|
|
174
|
+
if (!isLoopback(peer)) return refuse('peer-not-loopback');
|
|
175
|
+
const logins = headerValues(headers, IDENTITY_HEADER);
|
|
176
|
+
if (logins.length === 0) return refuse('identity-missing');
|
|
177
|
+
if (logins.length > 1) return refuse('identity-ambiguous');
|
|
178
|
+
let login = logins[0].trim();
|
|
179
|
+
if (!LOGIN.test(login)) return refuse('identity-malformed');
|
|
180
|
+
login = login.toLowerCase();
|
|
181
|
+
const groups = Object.keys(perimeter.groups).filter((n) => perimeter.groups[n].members.includes(login)).sort(pyKeySort);
|
|
182
|
+
if (groups.length === 0) return refuse('not-in-perimeter', login);
|
|
183
|
+
const dashboards = new Set(groups.flatMap((n) => perimeter.groups[n].dashboards));
|
|
184
|
+
const capabilities = new Set(groups.flatMap((n) => perimeter.groups[n].capabilities));
|
|
185
|
+
if (surface === 'dashboard') {
|
|
186
|
+
if (!dashboards.has(target)) return refuse('dashboard-not-granted', login, groups);
|
|
187
|
+
return { allowed: true, status: 200, code: 'ok', login, groups };
|
|
188
|
+
}
|
|
189
|
+
const presented = bearer(headers);
|
|
190
|
+
if (presented === null) return refuse('key-required', login, groups);
|
|
191
|
+
const digest = hashKey(presented);
|
|
192
|
+
const key = (keys ?? []).find((k) => sameDigest(k.sha256, digest));
|
|
193
|
+
if (!key) return refuse('key-unknown', login, groups);
|
|
194
|
+
if (key.login !== login) return refuse('key-not-yours', login, groups);
|
|
195
|
+
if (!key.capabilities.every((c) => capabilities.has(c))) return refuse('key-wider-than-group', login, groups);
|
|
196
|
+
if (!key.capabilities.includes(target)) return refuse('capability-not-granted', login, groups);
|
|
197
|
+
return { allowed: true, status: 200, code: 'ok', login, groups, key: key.id };
|
|
198
|
+
}
|
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():
|
|
@@ -364,6 +411,107 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
364
411
|
return None, None
|
|
365
412
|
|
|
366
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
|
+
|
|
367
515
|
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
368
516
|
"""The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
|
|
369
517
|
OS account's process (DEC-0154 HA-2)."""
|
|
@@ -373,13 +521,13 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
|
|
|
373
521
|
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
|
|
374
522
|
|
|
375
523
|
|
|
376
|
-
def launchd_override(label: str) -> Optional[str]:
|
|
524
|
+
def launchd_override(label: str, domain: Optional[str] = None) -> Optional[str]:
|
|
377
525
|
"""The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
|
|
378
526
|
none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
|
|
379
527
|
|
|
380
|
-
Reads ``launchctl print-disabled gui/<uid
|
|
528
|
+
Reads ``launchctl print-disabled gui/<uid>`` (``system`` with ``domain="system"``); macOS 14+ prints ``=> disabled|enabled``,
|
|
381
529
|
older releases ``=> true|false`` (true means disabled)."""
|
|
382
|
-
result = _launchctl("print-disabled",
|
|
530
|
+
result = _launchctl("print-disabled", launchd_domain(domain=domain))
|
|
383
531
|
if result.returncode != 0:
|
|
384
532
|
return "unknown"
|
|
385
533
|
for line in result.stdout.splitlines():
|
|
@@ -389,9 +537,63 @@ def launchd_override(label: str) -> Optional[str]:
|
|
|
389
537
|
return None
|
|
390
538
|
|
|
391
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
|
+
|
|
392
594
|
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
393
595
|
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
394
|
-
force_enable: bool = False) -> Dict[str, Any]:
|
|
596
|
+
force_enable: bool = False, domain: Optional[str] = None) -> Dict[str, Any]:
|
|
395
597
|
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
396
598
|
poll the well-known document until it answers with this identity.
|
|
397
599
|
|
|
@@ -399,38 +601,111 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
399
601
|
label. A label the operator disabled stays disabled and unloaded — the plist is still
|
|
400
602
|
rewritten, so the new release is what starts once they turn it back on — and the answer is
|
|
401
603
|
``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
|
|
402
|
-
``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."""
|
|
403
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>).")
|
|
404
655
|
atomic_write(plist_path, plist_bytes, 0o644)
|
|
405
656
|
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
406
657
|
if lint.returncode != 0:
|
|
407
658
|
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
408
|
-
target = "%s/%s" % (
|
|
409
|
-
override = launchd_override(label)
|
|
659
|
+
target = "%s/%s" % (dom, label)
|
|
660
|
+
override = launchd_override(label, which)
|
|
410
661
|
if force_enable or override is None:
|
|
411
662
|
_launchctl("enable", target)
|
|
412
663
|
elif override == "disabled":
|
|
413
|
-
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)}
|
|
414
665
|
_launchctl("bootout", target)
|
|
415
666
|
deadline = time.time() + 15
|
|
416
|
-
while launchd_loaded(label) and time.time() < deadline:
|
|
667
|
+
while launchd_loaded(label, which) and time.time() < deadline:
|
|
417
668
|
time.sleep(0.25)
|
|
418
669
|
last = ""
|
|
419
670
|
for _ in range(5):
|
|
420
|
-
result = _launchctl("bootstrap",
|
|
671
|
+
result = _launchctl("bootstrap", dom, str(plist_path))
|
|
421
672
|
if result.returncode == 0:
|
|
422
673
|
break
|
|
423
674
|
last = (result.stderr or result.stdout).strip()
|
|
424
675
|
time.sleep(1.0)
|
|
425
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))
|
|
426
682
|
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
427
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))
|
|
428
703
|
foreign = vouch = None
|
|
429
704
|
while time.time() < deadline:
|
|
430
705
|
doc, foreign = _read_well_known(origin, vouch_pid=vouch)
|
|
431
706
|
if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
|
|
432
707
|
vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
|
|
433
|
-
if doc
|
|
708
|
+
if _answers(doc, service_id, instance):
|
|
434
709
|
return doc
|
|
435
710
|
time.sleep(0.5)
|
|
436
711
|
if foreign:
|
|
@@ -445,20 +720,31 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
445
720
|
|
|
446
721
|
|
|
447
722
|
def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
|
|
448
|
-
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]:
|
|
449
725
|
"""Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
|
|
450
726
|
the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
|
|
451
727
|
override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
|
|
452
728
|
unless ``purge`` (then ``service_id`` names whose data, logs and cache go, and ``instance`` which
|
|
453
729
|
instance's: ``instance_dirs``, so one context's purge never reaches another's, FAA-125). A job still
|
|
454
|
-
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)."""
|
|
455
733
|
if purge and not service_id:
|
|
456
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)
|
|
457
743
|
dirs = instance_dirs(str(service_id), instance) if purge else {}
|
|
458
|
-
target = "%s/%s" % (
|
|
744
|
+
target = "%s/%s" % (dom, label)
|
|
459
745
|
_launchctl("bootout", target)
|
|
460
746
|
deadline = time.monotonic() + timeout
|
|
461
|
-
while launchd_loaded(label):
|
|
747
|
+
while launchd_loaded(label, which):
|
|
462
748
|
if time.monotonic() >= deadline:
|
|
463
749
|
raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
|
|
464
750
|
time.sleep(0.1)
|