@passioncode-ai/fabric-agent-adapter 0.72.0 → 0.74.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 (40) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +4 -3
  3. package/SECURITY.md +17 -0
  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 +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -16
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +4 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +36 -6
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +54 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +841 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent_schema.py +496 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +212 -9
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/service-channel-agent-extension.schema.json +700 -0
  35. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  36. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
  37. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +21 -8
  38. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +15 -6
  39. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  40. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -9,6 +9,8 @@ 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';
13
+ import http from 'node:http';
12
14
 
13
15
  export const DEFAULT_EXIT_TIMEOUT = 15;
14
16
 
@@ -316,13 +318,41 @@ export function launchdOverride(label, { run = defaultRun, platform = process.pl
316
318
  return null;
317
319
  }
318
320
 
319
- async function wellKnownOf(origin) {
320
- try {
321
- const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
322
- return response.ok ? await response.json() : null;
323
- } catch {
324
- return null;
321
+ // A refused listener, as the installer's poll sees it: never a document, whatever keys a service's JSON carries.
322
+ export class ForeignUserAnswer {
323
+ constructor(reason) { this.reason = reason; }
324
+ }
325
+
326
+ // The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
327
+ // (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
328
+ // account's or its owner cannot be told.
329
+ export async function wellKnownOf(origin) {
330
+ const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
331
+ if (!m) {
332
+ try {
333
+ const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
334
+ return response.ok ? await response.json() : null;
335
+ } catch {
336
+ return null;
337
+ }
325
338
  }
339
+ // A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
340
+ // answer, so a poll leaves no connection open behind it.
341
+ const agent = new CheckedAgent({ keepAlive: false });
342
+ return new Promise((resolve) => {
343
+ const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/.well-known/fabric-service', agent,
344
+ timeout: 2000 }, (res) => {
345
+ const chunks = [];
346
+ res.on('data', (c) => chunks.push(c));
347
+ res.on('end', () => {
348
+ try { resolve(res.statusCode === 200 ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null); } catch { resolve(null); }
349
+ });
350
+ res.on('error', () => resolve(null));
351
+ });
352
+ req.on('timeout', () => req.destroy());
353
+ req.on('error', (err) => resolve(err instanceof ListenerRefused ? new ForeignUserAnswer(`foreign-user: ${err.message}`) : null));
354
+ req.end();
355
+ }).finally(() => agent.destroy());
326
356
  }
327
357
 
328
358
  /**
@@ -359,11 +389,17 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
359
389
  await sleep(1000);
360
390
  }
361
391
  const deadline = Date.now() + timeout * 1000;
392
+ let foreign = null;
362
393
  while (Date.now() < deadline) {
363
394
  const doc = await fetchWellKnown(origin);
364
- if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
395
+ foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
396
+ if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
365
397
  await sleep(500);
366
398
  }
399
+ // DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
400
+ if (foreign) {
401
+ 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
+ }
367
403
  throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
368
404
  }
369
405
 
@@ -19,6 +19,10 @@ Rules (FAA-65 REQ-06):
19
19
  - **Secrets.** A plain `http://` URL is refused unless the host is loopback or `insecure=True`: a bearer token never
20
20
  crosses a network in clear. An error names the method and the URL without its userinfo, query and fragment
21
21
  (`redact_url`); it never carries a request header or a request body.
22
+ - **A loopback server is this account's** (DEC-0154 HA-2, FAA-226). Before a byte goes to a plain `http://` loopback
23
+ URL, the server end of the connection is told from the computer's socket table (`fabric_peer_owner`): another OS
24
+ account's, root's or SYSTEM's process, or one whose owner cannot be told, is `HttpError` of kind `foreign-user`,
25
+ nothing sent and never retried.
22
26
 
23
27
  The transport is injected in tests: `transport(method, url, headers, body, timeout_s, max_bytes)` returns
24
28
  `(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
@@ -45,6 +49,7 @@ from pathlib import Path as _Path # noqa: E402
45
49
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
46
50
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
47
51
  from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
52
+ import fabric_peer_owner as _po # noqa: E402 - beside this file: the listener's owner (DEC-0154 HA-2)
48
53
 
49
54
  RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
50
55
  IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS", "PUT", "DELETE")
@@ -66,7 +71,7 @@ class HttpError(Exception):
66
71
  self.attempts = attempts
67
72
  self.retry_after_s = retry_after_s
68
73
  self.body = bytes(body[:2048])
69
- self.kind = kind # http (an answer), connection, timeout, too-large, refused
74
+ self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
70
75
 
71
76
 
72
77
  class Response:
@@ -340,14 +345,30 @@ def _watched(base: type, watchdog: _Watchdog) -> type:
340
345
  return Watched
341
346
 
342
347
 
343
- def _opener(watchdog: _Watchdog) -> urllib.request.OpenerDirector:
348
+ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
349
+ """Plain http to a loopback host (DEC-0154 HA-2): `fabric_peer_owner.CheckedHTTPConnection`, whose every connect, a
350
+ reconnect included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's
351
+ process, built the way urllib builds a connection (`host` as `host:port`, `timeout` by keyword)."""
352
+
353
+ def __init__(self, host: str, port: Optional[int] = None, timeout: Any = socket._GLOBAL_DEFAULT_TIMEOUT,
354
+ **kwargs: Any) -> None:
355
+ http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
356
+ self.expect_pid = None
357
+
358
+
359
+ def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
360
+ """`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
361
+ owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
344
362
  class Http(urllib.request.HTTPHandler):
345
363
  def http_open(self, req):
346
- return self.do_open(_watched(http.client.HTTPConnection, watchdog), req)
364
+ local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
365
+ return self.do_open(_watched(_CheckedHTTPConnection if local else http.client.HTTPConnection, watchdog), req)
347
366
 
348
367
  class Https(urllib.request.HTTPSHandler):
349
368
  def https_open(self, req):
350
369
  return self.do_open(_watched(http.client.HTTPSConnection, watchdog), req, context=self._context)
370
+ if direct:
371
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
351
372
  return urllib.request.build_opener(_NoRedirect, Http, Https)
352
373
 
353
374
 
@@ -361,6 +382,9 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
361
382
  watchdog = _Watchdog(end)
362
383
  try:
363
384
  return _send(req, timeout_s, max_bytes, end, watchdog)
385
+ except _po.ListenerRefused as exc:
386
+ where = "%s %s" % (method, redact_url(url))
387
+ raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user") from None
364
388
  except (OSError, http.client.HTTPException) as exc:
365
389
  if watchdog.fired:
366
390
  raise TimeoutError("the answer did not arrive within the deadline")
@@ -372,7 +396,8 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
372
396
  def _send(req: urllib.request.Request, timeout_s: float, max_bytes: int, end: float,
373
397
  watchdog: _Watchdog) -> Tuple[int, Dict[str, str], bytes]:
374
398
  try:
375
- resp = _opener(watchdog).open(req, timeout=timeout_s)
399
+ resp = _opener(watchdog, direct=_is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())).open(
400
+ req, timeout=timeout_s)
376
401
  except urllib.error.HTTPError as answer: # a status outside 2xx is an answer, not a failure of the transport
377
402
  resp = answer
378
403
  except urllib.error.URLError as exc:
@@ -9,6 +9,9 @@ hub, and this module does exactly those, standard library only:
9
9
  FIFO; protocol `fabric-hub/0.1`; the origin exactly `http://127.0.0.1:<port>`; MCP at `/mcp`; a door token file
10
10
  inside the registry root; and a `pid` that is alive. The port does not prove who holds it: while Fabric is down any
11
11
  program can listen there, so a binding credential is sent only after this check passes, re-done before each send.
12
+ The pid is never the proof (DEC-0154 HA-2): every connection to the door is checked from the computer's socket table
13
+ before a byte goes out, and a listener of another OS account, or one whose owner cannot be told, is
14
+ `HubError("foreign-user")` with nothing sent (`fabric_peer_owner`).
12
15
  2. **Ask once, keep the credential in a file.** `request_board_access()` asks the operator through the door for the
13
16
  `board.respond` grant (callee `fabric`, resources `board:<project>/<capability>`); `poll_access()` writes the binding
14
17
  credential, shown once, to a 0600 file and returns the status without it. Tokens live in files and headers only:
@@ -21,7 +24,7 @@ hub, and this module does exactly those, standard library only:
21
24
  When Fabric is down every call raises `HubError` with `retryable=True`; `Backoff` gives the bounded, jittered wait a
22
25
  loop sleeps before trying again (2 s doubling to 5 minutes), so nothing spins.
23
26
 
24
- fabric-hub/0.1 publishes no hub key, so the door is trusted by the pid check alone. If a later `hub.json` names
27
+ fabric-hub/0.1 publishes no hub key; the door is trusted once its listener is this OS account's. If a later `hub.json` names
25
28
  `hubKey {kid, x}` and the caller passes an estate record (DEC-0055), `locate_hub(estate=…)` checks it with
26
29
  `fabric_estate.hub_key_certified` and refuses with DEC-0060's own detail (`kid-not-certified`, `estate-outside-window`,
27
30
  `estate-rolled-back`, `estate-forked`, `estate-unreadable`, `no-clock`)."""
@@ -40,6 +43,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
40
43
 
41
44
  sys.path.insert(0, str(Path(__file__).resolve().parent))
42
45
  import fabric_service as _fs # noqa: E402 - beside this file: token files, atomic writes
46
+ import fabric_peer_owner as _po # noqa: E402 - DEC-0154 HA-2: the hub's listener is this OS account's before a bearer
43
47
 
44
48
  HUB_PROTOCOL = "fabric-hub/0.1"
45
49
  MCP_PROTOCOL_VERSION = "2025-06-18"
@@ -51,7 +55,7 @@ CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
51
55
 
52
56
 
53
57
  class HubError(Exception):
54
- """A typed refusal or fault: `code` (`not-running`, `not-a-hub`, `hub-unavailable`, `credential`, `protocol`,
58
+ """A typed refusal or fault: `code` (`not-running`, `not-a-hub`, `hub-unavailable`, `foreign-user`, `credential`, `protocol`,
55
59
  `hub-key-refused`, or the hub tool's own code such as `not_authorized`), a one-sentence `message`, `retryable`, and
56
60
  `data` (never a secret). `str()` is the sentence."""
57
61
 
@@ -210,7 +214,9 @@ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[s
210
214
  method: str = "POST", protocol_version: Optional[str] = None) -> Tuple[int, Dict[str, str], Any]:
211
215
  """One MCP request to `<origin>/mcp` with a bearer in the header. Only `http://127.0.0.1:<port>` is accepted; no
212
216
  proxy is consulted. Answers (status, lower-cased headers, parsed JSON or None); an SSE answer is read to its first
213
- `data:` line. A refused connection is `HubError("hub-unavailable", retryable=True)`."""
217
+ `data:` line. A refused connection is `HubError("hub-unavailable", retryable=True)`; a listener that is not this OS
218
+ account's process is `HubError("foreign-user", retryable=True)` before the bearer is sent (DEC-0154 HA-2): Fabric
219
+ may come back on its port, and every retry checks again."""
214
220
  match = LOOPBACK_ORIGIN.fullmatch(origin)
215
221
  if not match:
216
222
  raise HubError("not-a-hub", "Only the hub's loopback origin is used.")
@@ -220,12 +226,15 @@ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[s
220
226
  headers["mcp-session-id"] = session
221
227
  if protocol_version:
222
228
  headers["mcp-protocol-version"] = protocol_version
223
- conn = http.client.HTTPConnection("127.0.0.1", int(match.group(1)), timeout=timeout)
229
+ conn = _po.CheckedHTTPConnection("127.0.0.1", int(match.group(1)), timeout=timeout)
224
230
  try:
225
231
  conn.request(method, "/mcp", body=None if method == "DELETE" else json.dumps(body).encode(), headers=headers)
226
232
  resp = conn.getresponse()
227
233
  raw = resp.read(4 * 1024 * 1024)
228
234
  got = {k.lower(): v for k, v in resp.getheaders()}
235
+ except _po.ListenerRefused as exc:
236
+ raise HubError("foreign-user", "The hub's port is not held by this OS account's process (%s); nothing was sent."
237
+ % (exc.owner.kind if exc.owner else exc.code), True) from None
229
238
  except (OSError, http.client.HTTPException) as exc:
230
239
  raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
231
240
  finally: