@passioncode-ai/fabric-agent-adapter 0.75.0 → 0.77.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 +98 -0
- package/README.md +9 -4
- package/SECURITY.md +13 -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 +4 -1
- 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/scripts/adapt_project.py +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +10 -8
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -0
- 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 +42 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +31 -7
- 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_http.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +132 -10
- 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 +47 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +194 -10
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -3
- 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/working-with-fabric/SKILL.md +6 -5
- 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/project-board.md +3 -1
- 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-peer-owner.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import http from 'node:http';
|
|
|
45
45
|
import net from 'node:net';
|
|
46
46
|
import os from 'node:os';
|
|
47
47
|
|
|
48
|
-
export const PEER_OWNER_VERSION = '1.1.0+kit.
|
|
48
|
+
export const PEER_OWNER_VERSION = '1.1.0+kit.3'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
|
|
49
49
|
export const THIS_ACCOUNT = 'this-account';
|
|
50
50
|
export const ANOTHER_ACCOUNT = 'another-account';
|
|
51
51
|
export const SYSTEM = 'system';
|
|
@@ -58,6 +58,10 @@ export const WINDOWS_TIMEOUT_MS = 5000;
|
|
|
58
58
|
export const LISTENER_TTL_MS = 1000;
|
|
59
59
|
export const NETSTAT = '/usr/sbin/netstat';
|
|
60
60
|
export const PS = '/bin/ps';
|
|
61
|
+
export const LSOF = '/usr/sbin/lsof';
|
|
62
|
+
// The reason a socket table hides other processes' sockets from this one (FAA-267): macOS 27.2 gives an ad-hoc-signed
|
|
63
|
+
// runtime an empty netstat.
|
|
64
|
+
export const TABLE_RESTRICTED = 'table-restricted';
|
|
61
65
|
export const SERVER_NOT_OWN = 'server-not-own';
|
|
62
66
|
export const SERVER_UNVERIFIED = 'server-unverified';
|
|
63
67
|
export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
|
|
@@ -143,6 +147,7 @@ function netstatAddress(text) {
|
|
|
143
147
|
// leading and the trailing columns, both counted from the header; a header without process:pid is unreadable.
|
|
144
148
|
export function parseNetstat(text) {
|
|
145
149
|
const lines = String(text).split(/\r?\n/);
|
|
150
|
+
if (!lines.some((line) => line.trim())) throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed nothing to this process`);
|
|
146
151
|
const header = lines.findIndex((line) => line.includes('process:pid') && line.includes('Proto'));
|
|
147
152
|
if (header < 0) throw new TableUnreadable('netstat-format: no process:pid column');
|
|
148
153
|
const names = lines[header].split(/\s+/).filter(Boolean);
|
|
@@ -165,6 +170,18 @@ export function parseNetstat(text) {
|
|
|
165
170
|
return rows;
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
// `lsof -nP -a -p PID -i4TCP -sTCP:LISTEN -Fn`: the `n` lines, 127.0.0.1:47480, *:47480 or [::1]:47480.
|
|
174
|
+
export function parseLsofListeners(text) {
|
|
175
|
+
const found = [];
|
|
176
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
177
|
+
if (!line.startsWith('n') || !line.includes(':')) continue;
|
|
178
|
+
const at = line.lastIndexOf(':');
|
|
179
|
+
const port = line.slice(at + 1);
|
|
180
|
+
if (/^[0-9]+$/.test(port)) found.push([normHost(line.slice(1, at).replace(/^\[|\]$/g, '')), Number(port)]);
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
// `ps -o uid= -p PID`: one number, or nothing when the process is gone.
|
|
169
186
|
export function parsePsUid(text) {
|
|
170
187
|
const t = String(text).trim();
|
|
@@ -497,10 +514,25 @@ export function refusal(who, port, expectPid = null) {
|
|
|
497
514
|
return null;
|
|
498
515
|
}
|
|
499
516
|
|
|
517
|
+
// macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on 127.0.0.1:<port>
|
|
518
|
+
// or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection). `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started
|
|
519
|
+
// `pid` itself (a launchd job it installed) may vouch with it.
|
|
520
|
+
export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine } = {}) {
|
|
521
|
+
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
522
|
+
try {
|
|
523
|
+
const uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(pid)]));
|
|
524
|
+
if (uid === null || uid !== (mine ?? ownUid())) return false;
|
|
525
|
+
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
526
|
+
return parseLsofListeners(out).some(([host, p]) => (host === '127.0.0.1' || host === '*') && p === port);
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
500
532
|
// A connected socket to host:port whose server end is this account's (and, with expectPid, that process's). The check
|
|
501
533
|
// runs on the very socket the caller then writes on, so no listener can change between the check and the request.
|
|
502
534
|
// Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
|
|
503
|
-
export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, ...ask } = {}) {
|
|
535
|
+
export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, vouchPid = null, ...ask } = {}) {
|
|
504
536
|
return new Promise((resolveSocket, reject) => {
|
|
505
537
|
const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
|
|
506
538
|
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
|
|
@@ -515,6 +547,11 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
|
|
|
515
547
|
// A reader that throws (an injected one; the readers themselves answer unknown) fails closed.
|
|
516
548
|
who = owner(UNKNOWN, { method: method(), reason: `check: ${err?.name ?? 'Error'}` });
|
|
517
549
|
}
|
|
550
|
+
if (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED) && vouchPid
|
|
551
|
+
&& await pidListens(vouchPid, port, ask)) {
|
|
552
|
+
// The table hides other processes (FAA-267); the process this caller started holds the listener.
|
|
553
|
+
who = owner(THIS_ACCOUNT, { uid: ask.ownUid ?? ownUid(), pid: vouchPid, method: 'lsof-pid' });
|
|
554
|
+
}
|
|
518
555
|
const refused = refusal(who, port, expectPid);
|
|
519
556
|
if (refused) {
|
|
520
557
|
socket.destroy();
|
|
@@ -532,14 +569,15 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
|
|
|
532
569
|
// when it is made. A refusal reaches the request as its 'error', a ListenerRefused. Free sockets do not keep the process
|
|
533
570
|
// alive (Node unrefs them).
|
|
534
571
|
export class CheckedAgent extends http.Agent {
|
|
535
|
-
constructor({ expectPid = null, ask = {}, ...options } = {}) {
|
|
572
|
+
constructor({ expectPid = null, vouchPid = null, ask = {}, ...options } = {}) {
|
|
536
573
|
super(options);
|
|
537
574
|
this.expectPid = expectPid;
|
|
575
|
+
this.vouchPid = vouchPid;
|
|
538
576
|
this.ask = ask;
|
|
539
577
|
}
|
|
540
578
|
|
|
541
579
|
createConnection(options, callback) {
|
|
542
580
|
checkedSocket(Number(options.port), { host: options.host ?? '127.0.0.1', timeoutMs: options.timeout ?? 10000,
|
|
543
|
-
expectPid: this.expectPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
581
|
+
expectPid: this.expectPid, vouchPid: this.vouchPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
544
582
|
}
|
|
545
583
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -148,6 +148,7 @@ export {
|
|
|
148
148
|
cookieValue,
|
|
149
149
|
} from './fabric-auth.mjs';
|
|
150
150
|
export {
|
|
151
|
+
LEVEL_ALIASES,
|
|
151
152
|
LEVELS,
|
|
152
153
|
EVENTS_DEFAULT_LIMIT,
|
|
153
154
|
EVENTS_MAX_LIMIT,
|
|
@@ -177,6 +178,7 @@ export {
|
|
|
177
178
|
taskUninstall,
|
|
178
179
|
launchdPlist,
|
|
179
180
|
launchdLoaded,
|
|
181
|
+
launchdPid,
|
|
180
182
|
launchdOverride,
|
|
181
183
|
launchdInstall,
|
|
182
184
|
launchdUninstall,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
|
|
|
9
9
|
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
|
-
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
12
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
|
|
13
13
|
import http from 'node:http';
|
|
14
14
|
|
|
15
15
|
export const DEFAULT_EXIT_TIMEOUT = 15;
|
|
@@ -55,13 +55,22 @@ function noMarkerArgument(argv) {
|
|
|
55
55
|
if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// An environment value: read path-aware (secretArgument) only when it reads as a path (it starts as one, has at least two
|
|
59
|
+
// separators and a lower-case word between them), so a release folder under Application Support is no key (FAA-266) while
|
|
60
|
+
// a base64 key whose `/` splits it stays one; any other value keeps the strict secretValue.
|
|
61
|
+
const secretEnvValue = (value) => {
|
|
62
|
+
const pieces = value.split(/[\\/]/);
|
|
63
|
+
const path = /^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) && pieces.length >= 3 && pieces.some((x) => /^[a-z][a-z0-9_-]{2,}$/.test(x));
|
|
64
|
+
return path ? secretArgument(value) : secretValue(value);
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
function noSecretInSupervisorFile(argv, environment) {
|
|
59
68
|
// No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
|
|
60
69
|
for (const arg of argv) {
|
|
61
70
|
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
|
|
62
71
|
}
|
|
63
72
|
for (const [name, value] of Object.entries(environment ?? {})) {
|
|
64
|
-
if (credentialName(name) ||
|
|
73
|
+
if (credentialName(name) || secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -264,7 +273,7 @@ export function launchdPlist(label, programArguments, { workingDirectory, stdout
|
|
|
264
273
|
const env = { ...environment };
|
|
265
274
|
for (const [name, value] of Object.entries(env)) {
|
|
266
275
|
if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
|
|
267
|
-
if (
|
|
276
|
+
if (secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
|
|
268
277
|
}
|
|
269
278
|
for (const arg of argv) {
|
|
270
279
|
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
|
|
@@ -302,6 +311,14 @@ export function launchdLoaded(label, { run = defaultRun, platform = process.plat
|
|
|
302
311
|
return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
// The running pid of this account's launchd job `label`, or null (not running, or not readable).
|
|
315
|
+
export function launchdPid(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
316
|
+
const result = run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`]);
|
|
317
|
+
if (result?.status !== 0) return null;
|
|
318
|
+
const hit = /^\s*pid\s*=\s*([0-9]+)\s*$/m.exec(String(result.stdout ?? ''));
|
|
319
|
+
return hit ? Number(hit[1]) : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
305
322
|
const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
|
|
306
323
|
|
|
307
324
|
/**
|
|
@@ -326,7 +343,7 @@ export class ForeignUserAnswer {
|
|
|
326
343
|
// The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
|
|
327
344
|
// (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
|
|
328
345
|
// account's or its owner cannot be told.
|
|
329
|
-
export async function wellKnownOf(origin) {
|
|
346
|
+
export async function wellKnownOf(origin, { vouchPid = null } = {}) {
|
|
330
347
|
const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
|
|
331
348
|
if (!m) {
|
|
332
349
|
try {
|
|
@@ -338,7 +355,7 @@ export async function wellKnownOf(origin) {
|
|
|
338
355
|
}
|
|
339
356
|
// A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
|
|
340
357
|
// answer, so a poll leaves no connection open behind it.
|
|
341
|
-
const agent = new CheckedAgent({ keepAlive: false });
|
|
358
|
+
const agent = new CheckedAgent({ keepAlive: false, vouchPid });
|
|
342
359
|
return new Promise((resolve) => {
|
|
343
360
|
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/.well-known/fabric-service', agent,
|
|
344
361
|
timeout: 2000 }, (res) => {
|
|
@@ -350,7 +367,9 @@ export async function wellKnownOf(origin) {
|
|
|
350
367
|
res.on('error', () => resolve(null));
|
|
351
368
|
});
|
|
352
369
|
req.on('timeout', () => req.destroy());
|
|
353
|
-
req.on('error', (err) => resolve(err instanceof ListenerRefused
|
|
370
|
+
req.on('error', (err) => resolve(err instanceof ListenerRefused
|
|
371
|
+
? new ForeignUserAnswer(`${String(err.owner?.reason ?? '').startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : 'foreign-user'}: ${err.message}`)
|
|
372
|
+
: null));
|
|
354
373
|
req.end();
|
|
355
374
|
}).finally(() => agent.destroy());
|
|
356
375
|
}
|
|
@@ -390,13 +409,18 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
390
409
|
}
|
|
391
410
|
const deadline = Date.now() + timeout * 1000;
|
|
392
411
|
let foreign = null;
|
|
412
|
+
let vouchPid = null;
|
|
393
413
|
while (Date.now() < deadline) {
|
|
394
|
-
const doc = await fetchWellKnown(origin);
|
|
414
|
+
const doc = await fetchWellKnown(origin, { vouchPid });
|
|
395
415
|
foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
416
|
+
if (foreign?.startsWith(TABLE_RESTRICTED)) vouchPid = launchdPid(label, opts); // only a restricted table needs it (FAA-267)
|
|
396
417
|
if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
397
418
|
await sleep(500);
|
|
398
419
|
}
|
|
399
420
|
// DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
|
|
421
|
+
if (foreign?.startsWith(TABLE_RESTRICTED)) {
|
|
422
|
+
throw new ServiceError(`${origin} could not be proved this service's: ${foreign} (DEC-0154 HA-2); this runtime sees no other process's sockets and the job's pid did not show the listener: run the installer from a runtime that sees them, or read the job's log.`);
|
|
423
|
+
}
|
|
400
424
|
if (foreign) {
|
|
401
425
|
throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
|
|
402
426
|
}
|
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())
|
|
@@ -354,6 +354,7 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
|
354
354
|
**kwargs: Any) -> None:
|
|
355
355
|
http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
|
|
356
356
|
self.expect_pid = None
|
|
357
|
+
self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
|
|
357
358
|
|
|
358
359
|
|
|
359
360
|
def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
|
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
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
|
|
16
16
|
a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
|
|
17
17
|
finds no row or several reads the table once more; ctypes is imported where it is used, so a
|
|
18
|
-
Python without it reads netstat on macOS and imports the module everywhere.
|
|
18
|
+
Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
|
|
19
|
+
macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
|
|
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=...)`, `own_connection(vouch_pid=...)`); since kit.3
|
|
22
|
+
the child's port is waited on with `selectors` and read under one deadline (FAA-273).
|
|
19
23
|
A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
|
|
20
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.
|
|
21
25
|
The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
|
|
@@ -64,6 +68,12 @@ addresses are `unknown` (`several-rows`), never a guess.
|
|
|
64
68
|
accept) and the listener and both ends of the pair must read back with their exact addresses,
|
|
65
69
|
this process's uid and its pid. A layout that fails calibration is not used again in that
|
|
66
70
|
process.
|
|
71
|
+
- macOS 27.2 restricts both tables for an ad-hoc-signed interpreter (a Homebrew Python, measured on 2026-11-07 on a
|
|
72
|
+
host agent's Mac): sysctl lists only this process's own sockets and netstat prints nothing. The calibration therefore
|
|
73
|
+
also requires a child process's listener, and an empty netstat is `unknown` with reason `table-restricted`, never
|
|
74
|
+
another account's (FAA-267). A caller that started the server itself (an installer and its launchd job) may vouch
|
|
75
|
+
with that pid: `lsof -p PID` asks the process, which the restriction leaves readable, and `CheckedHTTPConnection`
|
|
76
|
+
accepts its listener on the port as this account's (`vouch_pid`). Apple's own `/usr/bin/python3` sees the tables.
|
|
67
77
|
- macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then
|
|
68
78
|
`ps -o uid= -p PID` for the owner of the one row a question needs. Measured: about 20 ms over
|
|
69
79
|
846 sockets, and it names the pids of root's listeners, which `lsof` run by an ordinary account
|
|
@@ -100,7 +110,7 @@ import time
|
|
|
100
110
|
from typing import NamedTuple, Optional, Tuple
|
|
101
111
|
|
|
102
112
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
103
|
-
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
|
|
104
114
|
|
|
105
115
|
THIS_ACCOUNT = "this-account"
|
|
106
116
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -120,6 +130,11 @@ LISTENER_TTL = 1.0
|
|
|
120
130
|
|
|
121
131
|
NETSTAT = "/usr/sbin/netstat"
|
|
122
132
|
PS = "/bin/ps"
|
|
133
|
+
LSOF = "/usr/sbin/lsof"
|
|
134
|
+
|
|
135
|
+
#: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
|
|
136
|
+
#: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
|
|
137
|
+
TABLE_RESTRICTED = "table-restricted"
|
|
123
138
|
|
|
124
139
|
#: Client codes of `own_connection` (ListenerRefused.code).
|
|
125
140
|
SERVER_NOT_OWN = "server-not-own"
|
|
@@ -221,6 +236,8 @@ def parse_netstat(text: str) -> list[Row]:
|
|
|
221
236
|
`process:pid` column is what is left between the leading and the trailing columns, both
|
|
222
237
|
counted from the header. A header without `process:pid` (an older macOS) is unreadable."""
|
|
223
238
|
lines = text.splitlines()
|
|
239
|
+
if not any(line.strip() for line in lines):
|
|
240
|
+
raise TableUnreadable(f"{TABLE_RESTRICTED}: netstat printed nothing to this process")
|
|
224
241
|
header = next((i for i, line in enumerate(lines) if "process:pid" in line and "Proto" in line), None)
|
|
225
242
|
if header is None:
|
|
226
243
|
raise TableUnreadable("netstat-format: no process:pid column")
|
|
@@ -259,6 +276,18 @@ def _netstat_address(text: str) -> Optional[tuple[str, int]]:
|
|
|
259
276
|
return None
|
|
260
277
|
|
|
261
278
|
|
|
279
|
+
def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
|
|
280
|
+
"""`lsof -nP -a -p PID -i4TCP -sTCP:LISTEN -Fn`: the `n` lines, `127.0.0.1:47480`, `*:47480` or `[::1]:47480`."""
|
|
281
|
+
found: list[tuple[str, int]] = []
|
|
282
|
+
for line in text.splitlines():
|
|
283
|
+
if not line.startswith("n") or ":" not in line:
|
|
284
|
+
continue
|
|
285
|
+
host, port = line[1:].rsplit(":", 1)
|
|
286
|
+
if port.isdigit():
|
|
287
|
+
found.append((norm_host(host.strip("[]")), int(port)))
|
|
288
|
+
return found
|
|
289
|
+
|
|
290
|
+
|
|
262
291
|
def parse_ps_uid(text: str) -> Optional[int]:
|
|
263
292
|
"""`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
|
|
264
293
|
text = text.strip()
|
|
@@ -468,10 +497,75 @@ def calibration_holds(rows: list[Row], server: tuple[str, int], client: tuple[st
|
|
|
468
497
|
return (one(server, ("*", 0), True) and one(server, client, False) and one(client, server, False))
|
|
469
498
|
|
|
470
499
|
|
|
500
|
+
_CHILD_LISTENER = ("import socket,sys\ns=socket.socket();s.bind(('127.0.0.1',0));s.listen(1)\n"
|
|
501
|
+
"print(s.getsockname()[1],flush=True);sys.stdin.read()")
|
|
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
|
+
|
|
527
|
+
def _child_listener_seen(rows_of) -> Optional[bool]:
|
|
528
|
+
"""Whether the table shows a listener of a child process of this one: False when it does not (the table shows
|
|
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."""
|
|
532
|
+
if not sys.executable or getattr(sys, "frozen", False):
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
child = subprocess.Popen([sys.executable, "-I", "-c", _CHILD_LISTENER], stdin=subprocess.PIPE,
|
|
536
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
537
|
+
except OSError:
|
|
538
|
+
return None
|
|
539
|
+
deadline = time.monotonic() + TIMEOUT_SECONDS
|
|
540
|
+
port = None
|
|
541
|
+
try:
|
|
542
|
+
line = _child_line(child, deadline)
|
|
543
|
+
if line is None or not line.strip().isdigit():
|
|
544
|
+
return None
|
|
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
|
|
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.
|
|
553
|
+
try:
|
|
554
|
+
child.stdin.close()
|
|
555
|
+
if port is None:
|
|
556
|
+
child.kill()
|
|
557
|
+
child.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
558
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
559
|
+
child.kill()
|
|
560
|
+
child.wait()
|
|
561
|
+
child.stdout.close()
|
|
562
|
+
|
|
563
|
+
|
|
471
564
|
def _calibrate_sysctl() -> bool:
|
|
472
565
|
"""Open a connected pair of this process's own (listen, connect, accept) and read it back
|
|
473
|
-
(`calibration_holds`)
|
|
474
|
-
|
|
566
|
+
(`calibration_holds`), then require a child process's listener in the table too: a table that shows this process
|
|
567
|
+
alone (FAA-267) would pass the first check and find nobody else. A layout that does not hold is never trusted in
|
|
568
|
+
this process, and `netstat` answers instead."""
|
|
475
569
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
476
570
|
client = accepted = None
|
|
477
571
|
try:
|
|
@@ -480,8 +574,10 @@ def _calibrate_sysctl() -> bool:
|
|
|
480
574
|
client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
|
|
481
575
|
accepted, _ = probe.accept()
|
|
482
576
|
rows = parse_pcblist_n(_sysctl_blob())
|
|
483
|
-
|
|
484
|
-
uid=_posix_uid(), pid=os.getpid())
|
|
577
|
+
if not calibration_holds(rows, _address(probe.getsockname()), _address(client.getsockname()),
|
|
578
|
+
uid=_posix_uid(), pid=os.getpid()):
|
|
579
|
+
return False
|
|
580
|
+
return _child_listener_seen(lambda: parse_pcblist_n(_sysctl_blob())) is True
|
|
485
581
|
except (TableUnreadable, OSError, struct.error, ValueError):
|
|
486
582
|
return False
|
|
487
583
|
finally:
|
|
@@ -805,6 +901,23 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
|
|
|
805
901
|
return None
|
|
806
902
|
|
|
807
903
|
|
|
904
|
+
def pid_listens(pid: int, port: int) -> bool:
|
|
905
|
+
"""macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on
|
|
906
|
+
127.0.0.1:<port> or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection).
|
|
907
|
+
`lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that started `pid`
|
|
908
|
+
itself (a launchd job it installed) may vouch with it."""
|
|
909
|
+
if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
|
|
910
|
+
return False
|
|
911
|
+
try:
|
|
912
|
+
uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
|
|
913
|
+
if uid is None or uid != _posix_uid():
|
|
914
|
+
return False
|
|
915
|
+
out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
|
|
916
|
+
except TableUnreadable:
|
|
917
|
+
return False
|
|
918
|
+
return any(host in ("127.0.0.1", "*") and p == port for host, p in parse_lsof_listeners(out))
|
|
919
|
+
|
|
920
|
+
|
|
808
921
|
class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
809
922
|
"""An `http.client.HTTPConnection` whose every `connect()` checks the server end before
|
|
810
923
|
returning. `http.client` connects again by itself (`auto_open`) when the server answered
|
|
@@ -812,9 +925,11 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
|
812
925
|
different listener; so the check is in `connect()`, not once in `own_connection`. A refused
|
|
813
926
|
connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
|
|
814
927
|
|
|
815
|
-
def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0
|
|
928
|
+
def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
929
|
+
vouch_pid: Optional[int] = None) -> None:
|
|
816
930
|
super().__init__(host, port, timeout=timeout)
|
|
817
931
|
self.expect_pid = expect_pid
|
|
932
|
+
self.vouch_pid = vouch_pid
|
|
818
933
|
|
|
819
934
|
def set_tunnel(self, *args, **kwargs): # noqa: D401 - a tunnel would put a proxy's socket under the check
|
|
820
935
|
raise ListenerRefused(SERVER_UNVERIFIED, "a tunnel would check the proxy, not the server; nothing was sent")
|
|
@@ -822,6 +937,10 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
|
822
937
|
def connect(self) -> None:
|
|
823
938
|
super().connect()
|
|
824
939
|
owner = server_end(self.sock)
|
|
940
|
+
if (owner.kind == UNKNOWN and (owner.reason or "").startswith(TABLE_RESTRICTED) and getattr(self, "vouch_pid", None)
|
|
941
|
+
and pid_listens(self.vouch_pid, self.port)):
|
|
942
|
+
# The table hides other processes (FAA-267); the process this caller started holds the listener.
|
|
943
|
+
owner = Owner(THIS_ACCOUNT, _posix_uid(), self.vouch_pid, "lsof-pid")
|
|
825
944
|
refused = refusal(owner, self.port, self.expect_pid)
|
|
826
945
|
if refused is not None:
|
|
827
946
|
self.close()
|
|
@@ -829,7 +948,7 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
|
829
948
|
|
|
830
949
|
|
|
831
950
|
def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
832
|
-
host: str = "127.0.0.1") -> CheckedHTTPConnection:
|
|
951
|
+
host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
|
|
833
952
|
"""A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
|
|
834
953
|
(and, with `expect_pid`, that process's). The check runs on the very connection the caller
|
|
835
954
|
then sends on, so no listener can change between the check and the request, and it runs
|
|
@@ -839,7 +958,10 @@ def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: floa
|
|
|
839
958
|
server-not-own another account's or the system's process accepted it
|
|
840
959
|
server-unverified its owner could not be told (nothing is sent: fail closed)
|
|
841
960
|
server-not-this-workspace this account's process, but not the pid expected
|
|
961
|
+
|
|
962
|
+
`vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
|
|
963
|
+
(FAA-267).
|
|
842
964
|
"""
|
|
843
|
-
conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout)
|
|
965
|
+
conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
|
|
844
966
|
conn.connect()
|
|
845
967
|
return conn
|
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,
|
|
@@ -314,6 +315,7 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
314
315
|
_launchctl,
|
|
315
316
|
_domain,
|
|
316
317
|
launchd_loaded,
|
|
318
|
+
launchd_pid,
|
|
317
319
|
fetch_well_known,
|
|
318
320
|
_OVERRIDE_ROW,
|
|
319
321
|
launchd_override,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -53,6 +53,20 @@ def _no_marker_argument(argv: Sequence[str]) -> None:
|
|
|
53
53
|
raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
_PATH_ANCHOR = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/])")
|
|
57
|
+
_PATH_WORD = re.compile(r"[a-z][a-z0-9_-]{2,}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _secret_env_value(value: str) -> bool:
|
|
61
|
+
"""An environment value: read path-aware (`secret_argument`) only when it reads as a path (it starts as one, has at
|
|
62
|
+
least two separators and a lower-case word between them), so a release folder under Application Support is no key
|
|
63
|
+
(FAA-266) while a base64 key whose `/` splits it stays one; any other value keeps the strict `secret_value`."""
|
|
64
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
65
|
+
pieces = re.split(r"[\\/]", value)
|
|
66
|
+
path = bool(_PATH_ANCHOR.match(value)) and len(pieces) >= 3 and any(_PATH_WORD.fullmatch(x) for x in pieces)
|
|
67
|
+
return fabric_secrets.secret_argument(value) if path else fabric_secrets.secret_value(value)
|
|
68
|
+
|
|
69
|
+
|
|
56
70
|
def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
|
|
57
71
|
"""No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
|
|
58
72
|
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
@@ -60,7 +74,7 @@ def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dic
|
|
|
60
74
|
if fabric_secrets.secret_argument(arg):
|
|
61
75
|
raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
|
|
62
76
|
for name, value in (environment or {}).items():
|
|
63
|
-
if fabric_secrets.credential_name(name) or
|
|
77
|
+
if fabric_secrets.credential_name(name) or _secret_env_value(str(value)):
|
|
64
78
|
raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
|
|
65
79
|
|
|
66
80
|
|
|
@@ -263,7 +277,7 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
|
|
|
263
277
|
for key, value in env.items():
|
|
264
278
|
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
|
|
265
279
|
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
266
|
-
if
|
|
280
|
+
if _secret_env_value(str(value)): # path-aware only for a path: a release folder is no key (FAA-266)
|
|
267
281
|
raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
|
|
268
282
|
for arg in argv:
|
|
269
283
|
if fabric_secrets.secret_argument(arg):
|
|
@@ -307,19 +321,38 @@ def launchd_loaded(label: str) -> bool:
|
|
|
307
321
|
return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
|
|
308
322
|
|
|
309
323
|
|
|
310
|
-
|
|
324
|
+
_PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def launchd_pid(label: str) -> Optional[int]:
|
|
328
|
+
"""The running pid of this account's launchd job ``label``, or None (not running, or not readable)."""
|
|
329
|
+
result = _launchctl("print", "%s/%s" % (_domain(), label))
|
|
330
|
+
if result.returncode != 0:
|
|
331
|
+
return None
|
|
332
|
+
for line in (result.stdout or "").splitlines():
|
|
333
|
+
hit = _PID_ROW.match(line)
|
|
334
|
+
if hit:
|
|
335
|
+
return int(hit.group(1))
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _read_well_known(origin: str, timeout: float = 2.0,
|
|
340
|
+
vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
311
341
|
"""(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
|
|
312
342
|
connection whose server end is this OS account's process (DEC-0154 HA-2): another account's, root's, or one whose
|
|
313
|
-
owner cannot be told is `foreign-user`,
|
|
343
|
+
owner cannot be told is never this service's answer, labelled `foreign-user`, or `table-restricted` when the socket
|
|
344
|
+
table hides other processes. `vouch_pid`, the pid of a launchd job this
|
|
345
|
+
caller started, proves the listener when the socket table hides other processes (FAA-267)."""
|
|
314
346
|
parts = urllib.parse.urlsplit(origin)
|
|
315
347
|
if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
|
|
316
|
-
conn = _po.CheckedHTTPConnection("127.0.0.1", parts.port, timeout=timeout)
|
|
348
|
+
conn = _po.CheckedHTTPConnection("127.0.0.1", parts.port, timeout=timeout, vouch_pid=vouch_pid)
|
|
317
349
|
try:
|
|
318
350
|
conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % parts.port})
|
|
319
351
|
response = conn.getresponse()
|
|
320
352
|
return (json.loads(response.read().decode()) if response.status == 200 else None), None
|
|
321
353
|
except _po.ListenerRefused as exc:
|
|
322
|
-
|
|
354
|
+
restricted = exc.owner is not None and (exc.owner.reason or "").startswith(_po.TABLE_RESTRICTED)
|
|
355
|
+
return None, "%s: %s" % (_po.TABLE_RESTRICTED if restricted else "foreign-user", exc)
|
|
323
356
|
except (OSError, ValueError, http.client.HTTPException):
|
|
324
357
|
return None, None
|
|
325
358
|
finally:
|
|
@@ -392,14 +425,20 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
392
425
|
else:
|
|
393
426
|
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
394
427
|
deadline = time.time() + timeout
|
|
395
|
-
foreign = None
|
|
428
|
+
foreign = vouch = None
|
|
396
429
|
while time.time() < deadline:
|
|
397
|
-
doc, foreign = _read_well_known(origin)
|
|
430
|
+
doc, foreign = _read_well_known(origin, vouch_pid=vouch)
|
|
431
|
+
if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
|
|
432
|
+
vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
|
|
398
433
|
if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
|
|
399
434
|
return doc
|
|
400
435
|
time.sleep(0.5)
|
|
401
436
|
if foreign:
|
|
402
437
|
# DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
|
|
438
|
+
if foreign.startswith(_po.TABLE_RESTRICTED):
|
|
439
|
+
raise ServiceError("%s could not be proved this service's: %s (DEC-0154 HA-2); this interpreter sees no other "
|
|
440
|
+
"process's sockets and the job's pid did not show the listener: run the installer from "
|
|
441
|
+
"Apple's /usr/bin/python3, or read the job's log." % (origin, foreign))
|
|
403
442
|
raise ServiceError("%s is not this service's: %s (DEC-0154 HA-2); the port is another OS account's or its owner "
|
|
404
443
|
"cannot be told." % (origin, foreign))
|
|
405
444
|
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
|