@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +9 -4
  3. package/SECURITY.md +13 -1
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +55 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +10 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +10 -8
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +55 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +4 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +42 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +31 -7
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +9 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +132 -10
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +2 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +47 -8
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +194 -10
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -3
  28. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +55 -0
  29. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +2 -2
  30. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +5 -2
  31. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +2 -1
  32. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +6 -5
  33. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +55 -0
  34. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -1
  35. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -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.1'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
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
  }
@@ -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,
@@ -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) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
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 (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
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 ? new ForeignUserAnswer(`foreign-user: ${err.message}`) : null));
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
  }
@@ -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. `data` (DEC-0070) adds machine-readable
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:
@@ -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.1: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
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.1" # Observatory's 1.1.0 with the kit's differences above
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`). A layout that does not hold is never trusted in this process, and
474
- `netstat` answers instead."""
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
- return calibration_holds(rows, _address(probe.getsockname()), _address(client.getsockname()),
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) -> None:
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
@@ -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,
@@ -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 fabric_secrets.secret_value(str(value)):
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 fabric_secrets.secret_value(str(value)):
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
- def _read_well_known(origin: str, timeout: float = 2.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
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`, never this service's answer."""
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
- return None, "foreign-user: %s" % exc
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))