@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.
- package/CHANGELOG.md +154 -0
- package/README.md +4 -3
- package/SECURITY.md +17 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +36 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +54 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +841 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent_schema.py +496 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +212 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/service-channel-agent-extension.schema.json +700 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +21 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +15 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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:
|