@passioncode-ai/fabric-agent-adapter 0.73.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 (34) hide show
  1. package/CHANGELOG.md +114 -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 +14 -15
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +3 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +33 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +37 -3
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +122 -9
  31. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
  33. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  34. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -8,6 +8,13 @@
8
8
  --board-kit PATH (board.responder: the service's own fabric_board.py or fabric-board.mjs)
9
9
  --repo PATH (dashboard.opens-in-host: the service's checkout)
10
10
 
11
+ A local placement is read only when this OS account's process holds its port (DEC-0154 HA-2): `listener.owner`
12
+ comes before every rule that reads the port, from the computer's socket table (fabric_peer_owner). Nothing listening is NOT_RUN (`down`, and
13
+ `well-known.answers` fails as before); another OS account's, root's or SYSTEM's listener, or one whose owner cannot be
14
+ told, FAILs as `foreign-user`, and then no rule reads the port and no credential is sent; one `port.rules` NOT_RUN
15
+ line names the rules not read (as it does when the well-known document gives nothing to read). Every local
16
+ connection, a reconnect included, is checked again before a byte goes out on it.
17
+
11
18
  A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
12
19
  TLS with the certificate verified: the well-known document must refuse a request without the
13
20
  token (401 with none of the service's data: an empty body, or an identity proxy's page, DEC-0109), the guards must refuse a foreign Host/Origin and cross-site requests,
@@ -72,6 +79,8 @@ import fabric_service as fs # noqa: E402
72
79
  import fabric_connections as fcn # noqa: E402
73
80
  from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
74
81
  import fabric_interop as fi # noqa: E402
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
75
84
 
76
85
  Result = Dict[str, str]
77
86
 
@@ -153,13 +162,16 @@ class Probe:
153
162
  conn = http.client.HTTPSConnection(self.host_name, self.port, context=context, timeout=8)
154
163
  base = {"Host": self.netloc}
155
164
  else:
156
- conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
165
+ # DEC-0154 HA-2: every local connection, a reconnect included, is checked before a byte is sent on it.
166
+ conn = po.CheckedHTTPConnection("127.0.0.1", self.port, timeout=5)
157
167
  base = {"Host": "127.0.0.1:%d" % self.port}
158
168
  base.update(headers or {})
159
169
  try:
160
170
  conn.request(method, path, body=body, headers=base)
161
171
  resp = conn.getresponse()
162
172
  return resp.status, merge_headers(resp.getheaders()), resp.read()
173
+ except po.ListenerRefused as exc:
174
+ raise ForeignUser("foreign-user: %s" % exc) from None
163
175
  finally:
164
176
  conn.close()
165
177
 
@@ -168,6 +180,14 @@ class Probe:
168
180
  name, value = fs.token_header(self.d.get("auth"), self.token or "")
169
181
  return {name: value}
170
182
 
183
+ # the listener's owner (DEC-0154 HA-2) -------------------------------
184
+ def listener_rule(self) -> bool:
185
+ """DEC-0154 HA-2, `down` then `foreign-user` then the answer (check_service_rules.listener_owner_verdict). False
186
+ when the port must not be read (`foreign-user`)."""
187
+ verdict, evidence = listener_owner_verdict(self.port)
188
+ self.add("listener.owner", verdict, evidence)
189
+ return verdict != "FAIL"
190
+
171
191
  # descriptor ------------------------------------------------------------
172
192
  def descriptor_rules(self) -> None:
173
193
  problems = fs.validate_descriptor(self.d)
@@ -200,7 +220,7 @@ class Probe:
200
220
  elif "origin" not in self.d:
201
221
  unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
202
222
  else:
203
- unique = "port %d is unique" % self.port
223
+ unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % self.port
204
224
  self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
205
225
  # FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
206
226
  # is named here (NOT_RUN is no pass) until its owner adds it.
@@ -261,6 +281,12 @@ class Probe:
261
281
  self.add_no_token(no_token, None)
262
282
  self.add("well-known.answers", "FAIL", "body is not JSON")
263
283
  return
284
+ if not isinstance(self.wk, dict):
285
+ self.wk = None
286
+ if self.remote:
287
+ self.add_no_token(no_token, None)
288
+ self.add("well-known.answers", "FAIL", "body is not a JSON object")
289
+ return
264
290
  if self.remote:
265
291
  self.add_no_token(no_token, self.wk)
266
292
  self.add("well-known.answers", "PASS", "HTTP 200")
@@ -899,8 +925,16 @@ class Probe:
899
925
  return self.results
900
926
  if not self.port:
901
927
  return self.results
928
+ if not self.remote and not self.listener_rule():
929
+ self.add("port.rules", "NOT_RUN", "not read: " + PORT_RULES_UNREAD + ", because the listener is not this OS "
930
+ "account's (listener.owner FAIL, DEC-0154 HA-2)")
931
+ self.lifecycle_rules()
932
+ return self.results
902
933
  self.well_known_rules()
903
- if self.wk is not None:
934
+ if self.wk is None:
935
+ self.add("port.rules", "NOT_RUN", "not read: " + PORT_RULES_UNREAD + ", because well-known.answers above "
936
+ "got no document to read")
937
+ else:
904
938
  self.network_rules()
905
939
  self.auth_rules()
906
940
  self.usage_rules()
@@ -20,6 +20,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  import fabric_service as fs # noqa: E402 - beside this file
21
21
  from fabric_http import redact_url # noqa: E402
22
22
  from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
23
+ import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
24
+ import socket # noqa: E402
25
+
26
+
27
+ # The rules that ask the service's port, named on one NOT_RUN line when none of them can be read (0.74.0 product
28
+ # reading 8): a probe names what it could not check.
29
+ PORT_RULES_UNREAD = ("every rule that reads the port (well-known, network, auth, usage, A2A, dashboard, login, "
30
+ "interop, channel-agent, board)")
31
+
32
+
33
+ class ForeignUser(ConnectionError):
34
+ """DEC-0154 HA-2: the server end of a local connection is not this OS account's process (`foreign-user`); nothing
35
+ was sent on it. A ConnectionError, so every rule that reads the port reports it as no answer."""
36
+
37
+
38
+ def listener_owner_verdict(port: int) -> Tuple[str, str]:
39
+ """`listener.owner` for a local service: (verdict, evidence). `down`, then `foreign-user`, then everything the
40
+ listener answers: a local service is read only when this OS account's process holds its port. The owner is the
41
+ server end of a connection of the probe's own, on which nothing is sent (HA-2's source: the client's own connection;
42
+ two sockets of one account on one port, as SO_REUSEPORT workers hold, read as that account)."""
43
+ where = "127.0.0.1:%d" % port
44
+ try:
45
+ sock = socket.create_connection(("127.0.0.1", port), timeout=5)
46
+ except ConnectionRefusedError:
47
+ return "NOT_RUN", "nothing listens on %s (down)" % where
48
+ except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
49
+ return "NOT_RUN", "could not connect to %s to tell its owner (%s)" % (where, type(exc).__name__)
50
+ try:
51
+ owner = po.server_end(sock)
52
+ finally:
53
+ sock.close()
54
+ if owner.kind == po.THIS_ACCOUNT:
55
+ return "PASS", "%s is held by this OS account's process (pid %s, read by %s)" % (
56
+ where, owner.pid if owner.pid is not None else "not shown", owner.method)
57
+ why = {po.ANOTHER_ACCOUNT: "another OS account's process", po.SYSTEM: "a system account's process"}.get(
58
+ owner.kind, "a process whose owner cannot be told (%s)" % owner.reason)
59
+ return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
60
+ "as this service's" % (where, why, owner.method))
23
61
 
24
62
  _LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
25
63
  _SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
@@ -9,12 +9,17 @@
9
9
  // is safe: an idempotent method or an `Idempotency-Key` header; any other request only on 429. The wait before retry n
10
10
  // (from 0) is min(maxBackoffS, backoffBaseS * 2**n) * (0.5 + rand()); a `Retry-After` replaces it up to
11
11
  // maxRetryAfterS. A wait past the deadline is not taken. Plain http is refused unless the host is loopback or
12
- // `insecure`. An error names the method and the redacted URL, never a header or a body.
12
+ // `insecure`. An error names the method and the redacted URL, never a header or a body. Before a byte goes to a plain
13
+ // http loopback URL the server end of the connection is told from the socket table (DEC-0154 HA-2, FAA-226): not this
14
+ // OS account's process, or an owner that cannot be told, is an HttpError of kind foreign-user, nothing sent, not retried.
13
15
  //
14
16
  // The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
15
17
  // { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
16
18
  // HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
17
19
 
20
+ import http from 'node:http';
21
+ import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
22
+
18
23
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
19
24
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
20
25
  export const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
@@ -30,7 +35,7 @@ export class HttpError extends Error {
30
35
  this.attempts = attempts;
31
36
  this.retryAfterS = retryAfterS;
32
37
  this.body = body.slice(0, 2048);
33
- this.kind = kind; // http, connection, timeout, too-large, refused
38
+ this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
34
39
  }
35
40
  }
36
41
 
@@ -147,8 +152,53 @@ function checkHeaders(headers) {
147
152
  return out;
148
153
  }
149
154
 
150
- // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes.
155
+ const LOOPBACK_AGENT = new CheckedAgent();
156
+
157
+ // Plain http to a loopback host (DEC-0154 HA-2): node:http over a connection checked before a byte is written, no
158
+ // redirect followed, the body read to one byte past maxBytes. fetch cannot hand its socket to the check.
159
+ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes, agent = LOOPBACK_AGENT }) {
160
+ const parts = new URL(url);
161
+ // A body goes with its Content-Length, as fetch sent it: a server that reads only Content-Length (the kit's
162
+ // sample_service, the stdlib handlers) would read a chunked body as empty (0.74.0 seam reading 1).
163
+ const sent = { ...headers };
164
+ if (body && !Object.keys(sent).some((k) => k.toLowerCase() === 'content-length')) sent['Content-Length'] = String(body.length);
165
+ return new Promise((resolve, reject) => {
166
+ const req = http.request({ host: parts.hostname.replace(/^\[|\]$/g, ''), port: Number(parts.port || 80),
167
+ path: parts.pathname + parts.search, method, headers: sent, agent }, (res) => {
168
+ const chunks = [];
169
+ let size = 0;
170
+ res.on('data', (c) => {
171
+ if (size <= maxBytes) { chunks.push(c); size += c.length; }
172
+ if (size > maxBytes) res.destroy();
173
+ });
174
+ res.on('close', () => {
175
+ clearTimeout(timer);
176
+ const all = Buffer.concat(chunks);
177
+ const got = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : String(v)]));
178
+ if (!res.complete && size <= maxBytes) reject(Object.assign(new Error('the answer broke off'), { name: 'ConnectionError' }));
179
+ else resolve({ status: res.statusCode, headers: got, body: new Uint8Array(all.subarray(0, maxBytes + 1)) });
180
+ });
181
+ });
182
+ const timer = setTimeout(() => req.destroy(Object.assign(new Error('timed out'), { name: 'TimeoutError' })),
183
+ Math.max(1, Math.ceil(timeoutS * 1000)));
184
+ req.on('error', (err) => {
185
+ clearTimeout(timer);
186
+ if (err instanceof ListenerRefused) {
187
+ reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user' }));
188
+ } else reject(err);
189
+ });
190
+ if (body) req.write(body);
191
+ req.end();
192
+ });
193
+ }
194
+
195
+ // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http to a
196
+ // loopback host goes through checkedLoopbackTransport.
151
197
  export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
198
+ const target = new URL(url);
199
+ if (target.protocol === 'http:' && isLoopback(target.hostname)) {
200
+ return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes });
201
+ }
152
202
  const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
153
203
  signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
154
204
  const chunks = [];
@@ -6,7 +6,9 @@
6
6
  // $XDG_DATA_HOME/passioncode-fabric) the way Fabric's own locateHub does: a small regular file, never a link or a
7
7
  // FIFO; protocol fabric-hub/0.1; origin exactly http://127.0.0.1:<port>; MCP at /mcp; a door token file inside the
8
8
  // registry root; a pid that is alive. The port does not prove who holds it, so a binding credential is sent only
9
- // after this check passes, done again before each send.
9
+ // after this check passes, done again before each send. The pid is never the proof (DEC-0154 HA-2): every
10
+ // connection to the door is checked from the socket table before a byte goes out (fabric-peer-owner.mjs), and a
11
+ // listener of another OS account, or one whose owner cannot be told, is HubError foreign-user with nothing sent.
10
12
  // 2. Ask once, keep the credential in a file: requestBoardAccess() asks for the board.respond grant through the door;
11
13
  // pollAccess() writes the credential, shown once, to a 0600 file and answers without it. Tokens live in files and
12
14
  // headers only — never argv, a URL, a log line or an error text.
@@ -25,6 +27,12 @@ import os from 'node:os';
25
27
  import path from 'node:path';
26
28
  import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
27
29
  import { hubKeyCertified } from './fabric-estate.mjs';
30
+ import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
31
+
32
+ // DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
33
+ // socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
34
+ // keep-alive timeout (5 s in Node) can close it under a request that reuses it.
35
+ const HUB_AGENT = new CheckedAgent({ keepAlive: true, timeout: 2000 });
28
36
 
29
37
  export const HUB_PROTOCOL = 'fabric-hub/0.1';
30
38
  export const MCP_PROTOCOL_VERSION = '2025-06-18';
@@ -158,9 +166,10 @@ export function readCredential(file) {
158
166
  /**
159
167
  * One MCP request to <origin>/mcp with a bearer in the header; only http://127.0.0.1:<port>, no proxy. Answers
160
168
  * {status, headers, body} (body parsed JSON or null; an SSE answer read to its first data: line). A refused connection
161
- * is HubError hub-unavailable (retryable).
169
+ * is HubError hub-unavailable (retryable); a listener that is not this OS account's process is HubError foreign-user
170
+ * (retryable) before the bearer is sent (DEC-0154 HA-2): the connection is checked from the socket table first.
162
171
  */
163
- export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion } = {}) {
172
+ export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion, agent = HUB_AGENT } = {}) {
164
173
  const m = LOOPBACK_ORIGIN.exec(origin);
165
174
  if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
166
175
  const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
@@ -169,7 +178,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
169
178
  if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
170
179
  if (payload) headers['content-length'] = String(payload.length);
171
180
  return new Promise((resolve, reject) => {
172
- const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent: false }, (res) => {
181
+ const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent }, (res) => {
173
182
  const chunks = [];
174
183
  let size = 0;
175
184
  res.on('data', (c) => {
@@ -192,7 +201,9 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
192
201
  res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
193
202
  });
194
203
  req.on('timeout', () => req.destroy(new Error('timeout')));
195
- req.on('error', (e) => reject(new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
204
+ req.on('error', (e) => reject(e instanceof ListenerRefused
205
+ ? new HubError('foreign-user', `The hub's port is not held by this OS account's process (${e.owner?.kind ?? e.code}); nothing was sent.`, true)
206
+ : new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
196
207
  if (payload) req.write(payload);
197
208
  req.end();
198
209
  });