@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  52. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  54. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  73. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `18f8d48762edc5bfb1433657cbfe1c16196df1a6`
3
+ Pinned to `fabric-agent-contract` commit `93e7db164c7829e2783b4b7705c4113bb5201131`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -224,7 +224,8 @@ well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at
224
224
  The kit's module is `fabric_peer_owner` / `fabric-peer-owner.mjs` (layer L0, [kit modules](kit-modules.md)): `listener(port)`,
225
225
  `server_end(sock)` / `serverEnd(socket)`, `peer`, and a connection checked before a byte is written
226
226
  (`CheckedHTTPConnection`; Node `checkedSocket`, `CheckedAgent`). Python reads macOS through `sysctl
227
- net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`), Linux through `/proc/net/tcp`
227
+ net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`; the pinned HA-2 table names
228
+ `netstat -anv` and `pcblist64`, an older record layout of the same kernel table), Linux through `/proc/net/tcp`
228
229
  and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's token SID; Node reads `netstat -anv` and `ps`
229
230
  on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
230
231
  `this-account` refuses, `unknown` included.
@@ -238,7 +239,8 @@ listener of this account's pid on the port counts as this account's (`CheckedHTT
238
239
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
239
240
  whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
240
241
  IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDaemon's), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
241
- the contract's ruling on vouching is CT-261.
242
+ the pin's ruling on vouching is DEC-0155 RT-3 (the contract's CT-261), which vouches only for a listen on exactly
243
+ `127.0.0.1`: the kit's IPv4 wildcard vouch for a LaunchAgent departs from it until a later release (filed by FAA-258).
242
244
 
243
245
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
244
246
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
@@ -248,8 +250,23 @@ a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub cl
248
250
  is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
249
251
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
250
252
  twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
251
- misread; the Python twin's `sysctl` reader takes both from one record. A service's own loopback server refusing another account's peer (HA-3) is FAA-227, and the port
252
- claim file (HA-1) waits on the contract's CT-228.
253
+ misread; the Python twin's `sysctl` reader takes both from one record. The server side (HA-2, HA-3; FAA-227): the kit's `LoopbackHTTPServer` and Node's `loopbackServer(httpServer)` tell
254
+ each accepted connection's peer (`peer`) before a byte of the request is read, in the connection's own thread or
255
+ while the socket is still paused. `peer_check` / `peerCheck` (`PEER_CHECKS`): `refuse`, the default, closes a peer of
256
+ another OS account unread and serves the rest; `strict` also refuses an owner nobody can tell (a restricted table
257
+ included) and a system account other than root (uid 0, which can read every account's files anyway and installs a
258
+ LaunchDaemon; a Windows service account is refused); `off` reads no table. The check applies to loopback peers
259
+ (127.0.0.0/8, `::1`, either IPv4-mapped): HA-2 is about another account of this computer, so a peer at any other
260
+ address (a service bound to `0.0.0.0`) is served under `refuse` and `strict` alike without a table read, and its
261
+ owner is none (`peer_owner()` None, `connectionOwner` null); such a peer is never T0 or T1. Use `strict` for a dashboard, an MCP
262
+ surface or a token-free door on a computer with several counted accounts, where the interpreter sees the whole
263
+ table (Apple's `/usr/bin/python3`, not an ad-hoc-signed one on macOS 27.2). A refusal writes one line on stderr
264
+ (kind, uid, pid, never the peer's bytes); a handler finds its peer's owner with `self.server.peer_owner()` or
265
+ `connectionOwner(req.socket)`. The cost is one table read per connection, not per request: a few milliseconds from
266
+ `sysctl` or `/proc` in-process; Node on macOS and Windows runs `netstat` and `ps` or PowerShell for each, tens to
267
+ hundreds of milliseconds, so a Node service there that answers the well-known read within 100 ms keeps its
268
+ connections alive or chooses `off` where one account uses the computer. The port claim file (HA-1) waits on the
269
+ contract's CT-228.
253
270
 
254
271
  ## Semantic rules a host applies
255
272
 
@@ -127,6 +127,25 @@ async function serviceTarget(spec, dir) {
127
127
  return { url, declared, name: `${id}.${instance}` };
128
128
  }
129
129
 
130
+ // DEC-0154 PL-3 and HA-2 (FAA-260 items 2 and 14): a raw loopback address opens in the browser only while its port's
131
+ // listener is this OS account's, read from the socket table just before the page opens. The browser connects on its
132
+ // own, so this is the listener's owner, not that connection's: a listener swapped in between stays a residue
133
+ // (SECURITY.md). A copy of this file alone cannot tell the owner and opens no loopback page.
134
+ async function loopbackRefusal(target) {
135
+ let url;
136
+ try { url = new URL(target); } catch { return null; }
137
+ if (url.protocol !== 'http:') return null;
138
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
139
+ if (!(host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1'
140
+ || host === '0.0.0.0' || /^::ffff:(127\.|7f)/.test(host))) return null;
141
+ const po = await import('./fabric-peer-owner.mjs').catch(() => null);
142
+ if (!po) return 'fabric-peer-owner.mjs is not beside this file: the loopback listener\'s owner cannot be told, so the page is not opened (DEC-0154 HA-2)';
143
+ const port = Number(url.port || 80);
144
+ const who = await po.listener(port);
145
+ if (who.kind === po.THIS_ACCOUNT) return null;
146
+ return `foreign-user: the listener on port ${port} reads as ${who.kind}${who.reason ? ` (${who.reason})` : ''}, so the page is not opened (DEC-0154 PL-3, HA-2)`;
147
+ }
148
+
130
149
  // Runs inside the page. Everything it reports is measured from the rendered layout.
131
150
  function measure([floor, cap]) {
132
151
  const de = document.documentElement;
@@ -319,6 +338,8 @@ async function main(argv) {
319
338
  try {
320
339
  const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
321
340
  const page = await context.newPage();
341
+ const refused = await loopbackRefusal(target);
342
+ if (refused) return notRun(refused);
322
343
  const response = await page.goto(target, { waitUntil: 'load' });
323
344
  if (response && !response.ok()) {
324
345
  return notRun(`the page answered HTTP ${response.status()}`);
@@ -28,8 +28,14 @@ CREDENTIAL_PAIRS = {
28
28
  'session': {'token', 'id'},
29
29
  'code': {'login', 'auth', 'access', 'session'}}
30
30
  JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
- SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
- r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
31
+ # The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key with its
32
+ # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
33
+ # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
34
+ SECRET_VALUE = re.compile(r'(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"\']|\\u[0-9A-Fa-f]{4}'
35
+ r'|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m'
36
+ r'|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)'
37
+ r'(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})'
38
+ r'|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))', re.S)
33
39
  LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
40
 
35
41
 
@@ -115,6 +121,11 @@ def check(data):
115
121
  require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
116
122
  fallback = data.get('fallback', 'if_absent')
117
123
  require(fallback in ('if_absent', 'never'), 'invalid_fallback')
124
+ # DEC-0154 PL-3 and HA-2 (FAA-251): who listens on http_url's port, as the host read it from the socket table
125
+ # (fabric_peer_owner's kinds, or check_service.py's foreign-user). A browser opens a raw loopback address only
126
+ # after that listener is this OS account's; absent, it is unknown.
127
+ owner = data.get('listener_owner', 'unknown')
128
+ require(owner in ('this-account', 'another-account', 'system', 'unknown', 'none', 'foreign-user'), 'invalid_listener_owner')
118
129
  kind = action.get('kind')
119
130
  if kind == 'remote_open':
120
131
  require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
@@ -128,6 +139,7 @@ def check(data):
128
139
  require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
129
140
  require(viewer == target, 'wrong_viewer_device')
130
141
  require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
142
+ require(owner == 'this-account', 'listener_not_this_account')
131
143
  else:
132
144
  raise ValueError('unsupported_dashboard_action')
133
145
  return {'ok': True, 'kind': kind}
@@ -80,7 +80,7 @@ import fabric_connections as fcn # noqa: E402
80
80
  from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
81
81
  import fabric_interop as fi # noqa: E402
82
82
  import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2: the listener's owner before any credential
83
- from check_service_rules import ForeignUser, PORT_RULES_UNREAD, listener_owner_verdict # noqa: E402 - DEC-0154 HA-2
83
+ from check_service_rules import ForeignUser, PORT_RULES_UNREAD, in_launch_daemons, listener_owner_verdict, other_domain_copy, port_claim_verdict # noqa: E402 - DEC-0154 HA-2
84
84
 
85
85
  Result = Dict[str, str]
86
86
 
@@ -97,7 +97,7 @@ from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-6
97
97
  from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
98
98
  from check_service_rules import merge_headers, session_cookie # noqa: E402,F401 - FAA-115
99
99
  from check_service_rules import well_known_actions_verdict, action_tools_verdict, actions_declared_verdict # noqa: E402 - DEC-0121
100
- from check_service_rules import opens_in_host_verdict, service_checkout # noqa: E402 - FAA-118
100
+ from check_service_rules import opens_in_host_verdict, service_checkout, declared_limits, holds_verdict # noqa: E402 - FAA-118, FAA-298
101
101
  from check_service_rules import well_known_a2a_verdict, a2a_card_verdict # noqa: E402 - DEC-0122
102
102
  import fabric_a2a as fa2 # noqa: E402 - DEC-0122
103
103
  import check_service_channel_agent as cac # noqa: E402 - DEC-0150, FAA-197
@@ -153,6 +153,7 @@ class Probe:
153
153
 
154
154
  def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
155
155
  body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
156
+ """`connected_at`: when a local connect and its owner check ended, so the answer is timed from there (r11)."""
156
157
  if self.remote:
157
158
  context = ssl.create_default_context(cafile=self.ca_file) if self.ca_file else ssl.create_default_context()
158
159
  if self.connect:
@@ -167,6 +168,9 @@ class Probe:
167
168
  base = {"Host": "127.0.0.1:%d" % self.port}
168
169
  base.update(headers or {})
169
170
  try:
171
+ if not self.remote:
172
+ conn.connect()
173
+ self.connected_at = time.perf_counter()
170
174
  conn.request(method, path, body=body, headers=base)
171
175
  resp = conn.getresponse()
172
176
  return resp.status, merge_headers(resp.getheaders()), resp.read()
@@ -203,25 +207,7 @@ class Probe:
203
207
  else:
204
208
  mode = stat.S_IMODE(os.stat(self.path).st_mode)
205
209
  self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
206
- me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
207
- clashes = []
208
- for path, other in fs.read_descriptors(self.dir):
209
- key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
210
- if path.resolve() == self.path.resolve():
211
- continue
212
- if key == me:
213
- clashes.append("%s declared again in %s" % (me, path.name))
214
- # DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
215
- elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
216
- and other.get("origin") == self.d.get("origin")):
217
- clashes.append("port %d also claimed by %s" % (self.port, key))
218
- if self.remote:
219
- unique = "a remote origin claims no port here"
220
- elif "origin" not in self.d:
221
- unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
222
- else:
223
- unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % self.port
224
- self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
210
+ self.add("descriptor.port-claim", *port_claim_verdict(self.path, self.d, self.dir, self.port, self.remote))
225
211
  # FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
226
212
  # is named here (NOT_RUN is no pass) until its owner adds it.
227
213
  # A source descriptor.valid refuses is no pass either, and the evidence never carries a URL's userinfo or query,
@@ -260,9 +246,9 @@ class Probe:
260
246
  try:
261
247
  timings = []
262
248
  for _ in range(3):
263
- started = time.perf_counter()
249
+ started = self.connected_at = time.perf_counter()
264
250
  status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
265
- timings.append((time.perf_counter() - started) * 1000)
251
+ timings.append((time.perf_counter() - max(started, self.connected_at)) * 1000)
266
252
  except OSError as exc:
267
253
  if self.remote:
268
254
  self.add_no_token(no_token, None)
@@ -276,7 +262,7 @@ class Probe:
276
262
  try:
277
263
  self.wk = json.loads(body)
278
264
  self.wk_bytes = len(body) # D4 of the 0.73.0 brief: a well-known answer shares a 2 MiB cap
279
- except ValueError:
265
+ except (ValueError, RecursionError): # Python 3.9 to 3.13 raise RecursionError on deep nesting (unit reading 10)
280
266
  if self.remote:
281
267
  self.add_no_token(no_token, None)
282
268
  self.add("well-known.answers", "FAIL", "body is not JSON")
@@ -467,7 +453,7 @@ class Probe:
467
453
  self.add("usage.report", "FAIL", "HTTP %d with the token" % status)
468
454
  return
469
455
  try:
470
- report = json.loads(body)
456
+ report = self.usage_report = json.loads(body) # FAA-298: limits.holds reads its budgets
471
457
  except ValueError:
472
458
  self.add("usage.report", "FAIL", "not JSON")
473
459
  return
@@ -823,7 +809,7 @@ class Probe:
823
809
  except (OSError, ValueError) as exc:
824
810
  self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
825
811
  return
826
- problems = plist_problems(plist, life.get("label"), self.token)
812
+ problems = plist_problems(plist, life.get("label"), self.token, system=in_launch_daemons(plist_path), commands=self.d.get("commands"))
827
813
  self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
828
814
  marker = marker_problem(plist)
829
815
  self.add("lifecycle.supervisor-marker", "FAIL" if marker else "PASS",
@@ -843,6 +829,9 @@ class Probe:
843
829
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
844
830
  elif not match:
845
831
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
832
+ elif other_domain_copy(life.get("label"), domain): # loaded in both domains: two copies (FAA-286 item 6)
833
+ self.add("lifecycle.one-copy", "FAIL", "launchd job %s is loaded in %s and in %s: two copies; uninstall one"
834
+ % (life.get("label"), domain, other_domain_copy(life.get("label"), domain)))
846
835
  else:
847
836
  same = int(match.group(1)) == served
848
837
  self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
@@ -947,8 +936,8 @@ class Probe:
947
936
  self.login_rules()
948
937
  self.interop_rules()
949
938
  self.channel_agent_rule()
950
- verdict, evidence = board_responder_verdict(self.wk, self.board_kit)
951
- self.add("board.responder", verdict, evidence)
939
+ self.add("limits.holds", *holds_verdict(declared_limits(self.wk, getattr(self, "usage_report", None)), *service_checkout(self.d, self.repo)))
940
+ self.add("board.responder", *board_responder_verdict(self.wk, self.board_kit))
952
941
  self.lifecycle_rules()
953
942
  return self.results
954
943
 
@@ -457,7 +457,8 @@ def secrets(value: Any, root: str, budget: Budget) -> List[Dict[str, str]]:
457
457
  def flag(at: str, what: str, text: str, ident: bool) -> None:
458
458
  if budget.named < SECRET_FINDINGS:
459
459
  budget.named += 1
460
- rename = "; an id that starts with sk- is read as a key; rename it" if ident and re.match(r"sk-", text, re.I | re.A) else ""
460
+ # canonical case only (DEC-0151): an upper-case SK- is no key, so the advice would name the wrong cause
461
+ rename = "; an id that starts with sk- is read as a key; rename it" if ident and text.startswith("sk-") else ""
461
462
  findings.append(_finding("FAC-SEM-165", at, "%s is shaped like a credential: a token is named by its vault slot, never written%s (DEC-0150 CA-5)" % (what, rename)))
462
463
  else:
463
464
  budget.more += 1
@@ -22,6 +22,7 @@ import fabric_service as fs # noqa: E402 - beside this file
22
22
  from fabric_http import redact_url # noqa: E402
23
23
  from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
24
24
  import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
25
+ import fabric_supervisors as _sup # noqa: E402 - where a LaunchDaemon's plist lives (FAA-278)
25
26
  import socket # noqa: E402
26
27
 
27
28
 
@@ -47,7 +48,17 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
47
48
  except ConnectionRefusedError:
48
49
  return "NOT_RUN", "nothing listens on %s (down)" % where
49
50
  except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
50
- return "NOT_RUN", "could not connect to %s to tell its owner (%s)" % (where, type(exc).__name__)
51
+ # The listener table still names who holds the port (0.74.0 product reading 10, FAA-260 item 12): another
52
+ # account's or root's listener is foreign-user without a connection; any other owner is named beside NOT_RUN.
53
+ held = po.listener(port)
54
+ if held.kind in (po.ANOTHER_ACCOUNT, po.SYSTEM):
55
+ why = "another OS account's process" if held.kind == po.ANOTHER_ACCOUNT else "a system account's process"
56
+ return "FAIL", ("foreign-user: %s is listened on by %s (its listener read by %s; the connect failed with %s); "
57
+ "no credential was sent and nothing it answers is read as this service's"
58
+ % (where, why, held.method, type(exc).__name__))
59
+ named = held.kind if held.kind != po.UNKNOWN else "unknown (%s)" % held.reason
60
+ return "NOT_RUN", "could not connect to %s to tell its owner (%s); its listener reads as %s" % (
61
+ where, type(exc).__name__, named)
51
62
  try:
52
63
  owner = po.server_end(sock)
53
64
  finally:
@@ -60,6 +71,39 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
60
71
  return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
61
72
  "as this service's" % (where, why, owner.method))
62
73
 
74
+ def port_claim_verdict(path: Path, d: Dict[str, Any], directory: Path, port: int, remote: bool) -> Tuple[str, str]:
75
+ """`descriptor.port-claim`: (verdict, evidence). Another descriptor here declares this `id.instance`, or a local
76
+ origin on the same port, compared by the port as the writers refuse it, so `:08080` beside `:8080` is one port
77
+ (0.74.0 seam reading 10, FAA-260)."""
78
+ me = "%s.%s" % (d.get("id"), d.get("instance", "default"))
79
+ clashes = []
80
+ for other_path, other in fs.read_descriptors(directory):
81
+ key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
82
+ if other_path.resolve() == path.resolve():
83
+ continue
84
+ if key == me:
85
+ clashes.append("%s declared again in %s" % (me, other_path.name))
86
+ # DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
87
+ elif not remote and "origin" in d and fs.placement_of(other) != "remote" and port and _port_or_none(
88
+ other.get("origin")) == port:
89
+ clashes.append("port %d also claimed by %s" % (port, key))
90
+ if remote:
91
+ unique = "a remote origin claims no port here"
92
+ elif "origin" not in d:
93
+ unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
94
+ else:
95
+ unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % port
96
+ return ("FAIL" if clashes else "PASS"), ("; ".join(clashes) or unique)
97
+
98
+
99
+ def _port_or_none(origin: Any) -> Optional[int]:
100
+ """The port a local origin claims, or None for an origin the descriptor rules refuse (descriptor.valid names it)."""
101
+ try:
102
+ return fs.port_of(str(origin))
103
+ except fs.ServiceError:
104
+ return None
105
+
106
+
63
107
  _LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
64
108
  _SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
65
109
  _SPECIAL = re.compile(r"(?:https?|wss?|ftp|file):", re.I) # the URL standard's special schemes, read with or without `//`
@@ -91,9 +135,37 @@ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
91
135
  import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
92
136
 
93
137
 
94
- def plist_problems(plist: dict, label: object, token: object = None) -> list:
95
- """What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
138
+ def other_domain_copy(label: object, domain: str) -> Optional[str]:
139
+ """The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None."""
140
+ other = ("gui/%d" % getattr(os, "getuid", lambda: 0)()) if domain == "system" else "system"
141
+ try:
142
+ out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True, timeout=10)
143
+ except (OSError, subprocess.SubprocessError):
144
+ return None
145
+ return other if out.returncode == 0 else None
146
+
147
+
148
+ def in_launch_daemons(plist_path: Path) -> bool:
149
+ """Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278)."""
150
+ folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
151
+ return folder.lower() == str(_sup.SYSTEM_DAEMONS).lower() # the supervisors' own, which a test may point elsewhere
152
+
153
+
154
+ def plist_problems(plist: dict, label: object, token: object = None, *, system: bool = False,
155
+ commands: object = None) -> list:
156
+ """What makes a service's launchd job unfit to stay up: identity, restart, secrets. A LaunchDaemon (`system`) also
157
+ needs UserName, or it runs as root (FAA-286 item 22), and its descriptor's commands an absolute path, since a
158
+ `~` would be root's home when the job's update runs as root (item 23)."""
96
159
  problems = []
160
+ if system and not (isinstance(plist.get("UserName"), str) and plist.get("UserName")):
161
+ problems.append("a LaunchDaemon without UserName runs as root, and every client refuses root's listener "
162
+ "(DEC-0154 HA-2)")
163
+ if system and isinstance(commands, dict):
164
+ for name, argv in sorted(commands.items()):
165
+ first = argv[0] if isinstance(argv, list) and argv else argv
166
+ if isinstance(first, str) and first.startswith("~"):
167
+ problems.append("commands.%s starts with ~, which is root's home for a LaunchDaemon: write the absolute "
168
+ "path" % name)
97
169
  if plist.get("Label") != label:
98
170
  problems.append("Label %r" % plist.get("Label"))
99
171
  if plist.get("RunAtLoad") is not True:
@@ -729,3 +801,63 @@ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Pat
729
801
  if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
730
802
  return Path(top), "the checkout of %s that holds %s" % (shown_repository(source), place)
731
803
  return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % shown_repository(source)
804
+
805
+
806
+ # FAA-298: a service with limits of its own holds work visibly (fabric_holds), never silently.
807
+ _HOLD_USE = "HoldBook|waiting_message|waitingMessage|hold_job|holdJob" # git grep -E is POSIX: no word boundary
808
+ #: the kit's own copies, and a vendored copy of this file (its pattern names the kit's words): neither is a use
809
+ _HOLD_KIT_FILES = ("fabric_holds.py", "fabric-holds.mjs", "check_service_rules.py")
810
+ #: only code holds work: a README or a note naming HoldBook is no use of it (0.81.0 seam reading)
811
+ _HOLD_CODE = ("*.py", "*.mjs", "*.js", "*.ts")
812
+
813
+
814
+ def declared_limits(wk: Any, usage_report: Any = None) -> List[str]:
815
+ """The limits a service declares: the load declaration's `limits` (DEC-0104), and the usage report's `budget` and
816
+ `budgets` (DEC-0021, DEC-0027) when the probe read one. An invalid load declaration reads as not declared."""
817
+ import fabric_load # noqa: E402 - beside this file
818
+ found: List[str] = []
819
+ view = fabric_load.load_view(wk)
820
+ limits = (view.get("load") or {}).get("limits") if view.get("valid") else None
821
+ if isinstance(limits, dict):
822
+ found += ["load %s %s" % (name, limits[name]) for name in ("concurrency", "perCaller", "queueMax") if name in limits]
823
+ if isinstance(usage_report, dict):
824
+ budget = usage_report.get("budget")
825
+ if isinstance(budget, dict) and "limitUsd" in budget:
826
+ found.append("usage budget USD %s a %s" % (budget.get("limitUsd"), budget.get("period")))
827
+ budgets = usage_report.get("budgets")
828
+ if isinstance(budgets, list) and budgets:
829
+ found.append("usage budgets (%d)" % len(budgets))
830
+ return found
831
+
832
+
833
+ def holds_verdict(limits: List[str], repo: Optional[Path], why: str,
834
+ budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
835
+ """`limits.holds` over one checkout: PASS when a tracked file outside the kit's own copy and the tests holds work
836
+ through fabric_holds (HoldBook, waiting_message, hold_job) in a code file (.py, .mjs, .js, .ts); NOT_RUN when it
837
+ declares none, or its checkout or git cannot be read in the budget. A service that declares limits and holds none
838
+ through the kit is NOT_RUN with a migration note naming fabric_holds, never FAIL: services built before the module
839
+ existed could not have adopted it, and whether one holds work silently or refuses it cannot be read from its tree."""
840
+ if not limits:
841
+ return "NOT_RUN", "the service declares no limits (a load declaration's limits, a usage report's budget or budgets)"
842
+ if repo is None:
843
+ return "NOT_RUN", why
844
+ if not shutil.which("git"):
845
+ return "NOT_RUN", "git is not installed"
846
+ try:
847
+ listed = subprocess.run(["git", "-C", str(repo), "grep", "-I", "-l", "-z", "-E", _HOLD_USE, "--"]
848
+ + list(_HOLD_CODE), capture_output=True,
849
+ timeout=max(0.05, budget))
850
+ except subprocess.TimeoutExpired:
851
+ return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
852
+ except OSError as exc:
853
+ return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
854
+ if listed.returncode not in (0, 1):
855
+ return "NOT_RUN", "git grep could not read %s" % repo
856
+ users = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0")
857
+ if p and p.rsplit("/", 1)[-1] not in _HOLD_KIT_FILES and not _TEST_PATH.search(p)]
858
+ if users:
859
+ return "PASS", "declares %s; %s hold(s) work through fabric_holds" % ("; ".join(limits), ", ".join(users[:5]))
860
+ return "NOT_RUN", ("migration: declares %s, and no tracked code file outside the kit and the tests holds work "
861
+ "through fabric_holds (HoldBook, waiting_message, hold_job) yet. Adopt fabric_holds so each hold "
862
+ "is said on the job, the operator is pushed once per hold and takes a scoped lift (FAA-298, "
863
+ "references/interop.md); until then this rule is not run" % "; ".join(limits))
@@ -2,11 +2,12 @@
2
2
  // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
+ import net from 'node:net';
5
6
  import path from 'node:path';
6
7
  import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
7
8
  import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
8
9
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
- import { ANOTHER_ACCOUNT } from './fabric-peer-owner.mjs'; // L0: who listens on a port (DEC-0154 HA-2, FAA-225)
10
+ import { ANOTHER_ACCOUNT, SYSTEM, THIS_ACCOUNT, UNKNOWN, normHost, owner as peerOwnerOf, peer as peerOfSocket } from './fabric-peer-owner.mjs'; // L0: who listens on a port and who connected (DEC-0154 HA-2, FAA-225, FAA-227)
10
11
  import { protocolsFindings } from './fabric-protocols.mjs';
11
12
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
12
13
  import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
@@ -329,6 +330,82 @@ server.once('error', async () => {
329
330
  server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
330
331
  `;
331
332
 
333
+ // How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
334
+ // FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of it
335
+ // and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account other
336
+ // than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table. The twin
337
+ // of fabric_descriptor.py's PEER_CHECKS and LoopbackHTTPServer.
338
+ export const PEER_CHECKS = Object.freeze(['refuse', 'strict', 'off']);
339
+
340
+ /** Why a server refuses the peer `owner` (a fabric-peer-owner owner) under `mode`, or null to serve it: the owner's
341
+ * kind, or for `unknown` its reason; never an address or anything the peer sent. */
342
+ export function connectionRefusal(owner, mode = 'refuse') {
343
+ if (!PEER_CHECKS.includes(mode)) throw new ServiceError(`peerCheck must be one of ${PEER_CHECKS.join(', ')}.`);
344
+ if (mode === 'off' || owner === null || owner === undefined) return null;
345
+ const kind = owner.kind ?? UNKNOWN;
346
+ if (kind === ANOTHER_ACCOUNT) return kind;
347
+ if (mode === 'strict' && kind !== THIS_ACCOUNT) {
348
+ if (kind === SYSTEM && owner.uid === 0) return null;
349
+ return owner.reason || kind;
350
+ }
351
+ return null;
352
+ }
353
+
354
+ /** Whether a connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the only peers
355
+ * whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read counts as
356
+ * loopback, so its owner is still read from the table. The twin of fabric_descriptor.py's is_loopback_peer. */
357
+ export function isLoopbackPeer(address) {
358
+ if (typeof address !== 'string') return true;
359
+ const host = normHost(address);
360
+ if (net.isIPv4(host)) return host.split('.')[0] === '127';
361
+ if (net.isIPv6(host)) return host === '::1';
362
+ return true;
363
+ }
364
+
365
+ const connectionOwners = new WeakMap();
366
+
367
+ /** The owner of the peer behind an HTTP request's socket (`req.socket`) a loopbackServer served, or null. */
368
+ export function connectionOwner(socket) {
369
+ return connectionOwners.get(socket) ?? null;
370
+ }
371
+
372
+ /** A net.Server to listen on in place of `httpServer` (DEC-0154 HA-2's server side, FAA-227): each connection arrives
373
+ * paused, its peer is told from the computer's socket table (`peer`, fabric-peer-owner's by default), and only a peer
374
+ * `peerCheck` serves is handed to `httpServer` (never listened on itself), which then reads the request; a refused one
375
+ * is destroyed unread, counted in `peerRefusals`, and reported to `onRefused(owner, reason)` (default: one line on
376
+ * stderr with the owner's kind, uid and pid). Handlers find their peer's owner with `connectionOwner(req.socket)`.
377
+ * The check applies to loopback peers only: a peer at another address (a server bound to 0.0.0.0) is no local
378
+ * account, so its table is not read and it is served under `refuse` and `strict` alike, with no owner.
379
+ * Close the returned server to stop accepting; `httpServer.closeAllConnections()` ends the served ones. */
380
+ export function loopbackServer(httpServer, { peerCheck = 'refuse', peer = peerOfSocket, onRefused } = {}) {
381
+ connectionRefusal(null, peerCheck);
382
+ const report = onRefused ?? ((who, reason) => process.stderr.write(
383
+ `fabric: refused a loopback peer (${reason}, uid ${who?.uid ?? 'None'}, pid ${who?.pid ?? 'None'}) before reading it (DEC-0154)\n`));
384
+ const gate = net.createServer({ pauseOnConnect: true }, async (socket) => {
385
+ let who = null;
386
+ if (peerCheck !== 'off' && isLoopbackPeer(socket.remoteAddress)) {
387
+ try {
388
+ who = await peer(socket);
389
+ } catch (err) {
390
+ who = peerOwnerOf(UNKNOWN, { reason: `reader: ${err?.name ?? 'Error'}` });
391
+ }
392
+ }
393
+ const reason = connectionRefusal(who, peerCheck);
394
+ if (reason !== null) {
395
+ gate.peerRefusals += 1;
396
+ socket.destroy();
397
+ report(who, reason);
398
+ return;
399
+ }
400
+ if (socket.destroyed) return;
401
+ if (who !== null) connectionOwners.set(socket, who);
402
+ httpServer.emit('connection', socket);
403
+ socket.resume();
404
+ });
405
+ gate.peerRefusals = 0;
406
+ return gate;
407
+ }
408
+
332
409
  /** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
333
410
  * (FAA-225, DEC-0154 HA-2). */
334
411
  export function portOwner(port) {