@passioncode-ai/fabric-agent-adapter 0.77.0 → 0.79.1

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 (74) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +31 -11
  3. package/SECURITY.md +17 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +15 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +27 -39
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +74 -3
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +339 -26
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +367 -27
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +323 -25
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -6
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  62. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  63. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  64. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  65. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  66. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  67. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +30 -39
  68. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  69. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -0,0 +1,198 @@
1
+ // Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). The twin of
2
+ // fabric_tailnet.py; both answer test/fixtures/tailnet/vectors.json alike. No dependencies beyond node:crypto and
3
+ // node:net.
4
+ //
5
+ // The header `Tailscale-User-Login` is believed only on the agent's own serve listener (a loopback port or a Unix
6
+ // socket that only serve reaches) and only from a loopback peer; on any other listener a request carrying it is
7
+ // refused. What a person may open comes from a `tailnet-perimeter/1` file generated from the organization's access
8
+ // registry (it names its source in `generatedFrom`); MCP also takes a personal key, stored as its SHA-256, owned by one
9
+ // login and no wider than that login's groups. Refusals are 401 or 403 with a stable `code`.
10
+
11
+ import { createHash, timingSafeEqual } from 'node:crypto';
12
+ import { isIP } from 'node:net';
13
+
14
+ export const PERIMETER_SCHEMA = 'tailnet-perimeter/1';
15
+ export const IDENTITY_HEADER = 'tailscale-user-login';
16
+ export const SERVE_HEADERS = Object.freeze(['tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic']);
17
+ export const LISTENER_SERVE = 'serve';
18
+ export const SURFACES = Object.freeze(['dashboard', 'mcp']);
19
+
20
+ const LOGIN = /^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$/;
21
+ const NAME = /^[a-z][a-z0-9._:/-]{0,127}$/;
22
+ const KEY_HASH = /^sha256:[0-9a-f]{64}$/;
23
+ const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
24
+
25
+ export const REFUSALS = Object.freeze({
26
+ 'serve-header-off-serve': 403,
27
+ 'peer-not-loopback': 403,
28
+ 'identity-missing': 401,
29
+ 'identity-ambiguous': 401,
30
+ 'identity-malformed': 401,
31
+ 'not-in-perimeter': 403,
32
+ 'dashboard-not-granted': 403,
33
+ 'capability-not-granted': 403,
34
+ 'key-required': 401,
35
+ 'key-unknown': 401,
36
+ 'key-not-yours': 403,
37
+ 'key-wider-than-group': 403,
38
+ });
39
+
40
+ /** A perimeter or key list that cannot be used: `code` names why, `where` the place, never a value. */
41
+ export class PerimeterError extends Error {
42
+ constructor(code, where = '') {
43
+ super(`${code}${where ? ` at ${where}` : ''}`);
44
+ this.name = 'PerimeterError';
45
+ this.code = code;
46
+ this.where = where;
47
+ }
48
+ }
49
+
50
+ /** The stored form of a personal key: `sha256:<hex>` of its UTF-8 bytes. */
51
+ export function hashKey(value) {
52
+ if (typeof value !== 'string' || !value) throw new TypeError('a key is a non-empty string');
53
+ return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
54
+ }
55
+
56
+ const sortedSet = (values) => [...new Set(values)].sort();
57
+ const isRecordValue = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
58
+ const pyKeySort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
59
+
60
+ function names(value, where) {
61
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && NAME.test(v))) {
62
+ throw new PerimeterError('perimeter-invalid', where);
63
+ }
64
+ return sortedSet(value);
65
+ }
66
+
67
+ /** Check a parsed `tailnet-perimeter/1` document and answer its normalized form; throw PerimeterError otherwise. */
68
+ export function loadPerimeter(doc) {
69
+ if (!isRecordValue(doc) || doc.schema !== PERIMETER_SCHEMA) throw new PerimeterError('perimeter-schema', 'schema');
70
+ const source = doc.generatedFrom;
71
+ if (!isRecordValue(source) || !['registry', 'revision', 'at'].every((k) => typeof source[k] === 'string' && source[k])) {
72
+ throw new PerimeterError('perimeter-not-generated', 'generatedFrom');
73
+ }
74
+ const groups = doc.groups;
75
+ if (!isRecordValue(groups) || Object.keys(groups).length === 0) throw new PerimeterError('perimeter-invalid', 'groups');
76
+ const out = {};
77
+ for (const name of Object.keys(groups).sort(pyKeySort)) {
78
+ const group = groups[name];
79
+ const where = NAME.test(name) ? `groups.${name}` : 'groups';
80
+ if (!NAME.test(name) || !isRecordValue(group)) throw new PerimeterError('perimeter-invalid', where);
81
+ const members = group.members;
82
+ if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
83
+ throw new PerimeterError('perimeter-invalid', `${where}.members`);
84
+ }
85
+ out[name] = {
86
+ members: sortedSet(members.map((m) => m.toLowerCase())),
87
+ dashboards: names(group.dashboards ?? [], `${where}.dashboards`),
88
+ capabilities: names(group.capabilities ?? [], `${where}.capabilities`),
89
+ };
90
+ }
91
+ return {
92
+ schema: PERIMETER_SCHEMA,
93
+ generatedFrom: { registry: source.registry, revision: source.revision, at: source.at },
94
+ groups: out,
95
+ };
96
+ }
97
+
98
+ /** Check a list of personal keys `{id, login, sha256, capabilities}` (the hash only, never the key). */
99
+ export function loadKeys(keys) {
100
+ if (!Array.isArray(keys)) throw new PerimeterError('keys-invalid', 'keys');
101
+ const out = [];
102
+ const seen = new Set();
103
+ keys.forEach((key, i) => {
104
+ const where = `keys[${i}]`;
105
+ if (!isRecordValue(key) || typeof key.id !== 'string' || !KEY_ID.test(key.id)
106
+ || typeof key.login !== 'string' || !LOGIN.test(key.login)
107
+ || typeof key.sha256 !== 'string' || !KEY_HASH.test(key.sha256)) {
108
+ throw new PerimeterError('keys-invalid', where);
109
+ }
110
+ if (seen.has(key.id) || out.some((k) => k.sha256 === key.sha256)) throw new PerimeterError('keys-duplicate', where);
111
+ seen.add(key.id);
112
+ out.push({ id: key.id, login: key.login.toLowerCase(), sha256: key.sha256,
113
+ capabilities: names(key.capabilities ?? [], `${where}.capabilities`) });
114
+ });
115
+ return out;
116
+ }
117
+
118
+ function headerValues(headers, name) {
119
+ // A list of pairs, a Map or a fetch Headers (anything with entries()), or a plain object such as Node's req.headers.
120
+ const plain = headers === null || headers === undefined || Object.getPrototypeOf(headers) === Object.prototype
121
+ || Object.getPrototypeOf(headers) === null;
122
+ const pairs = Array.isArray(headers) ? headers
123
+ : !plain && typeof headers.entries === 'function' ? [...headers.entries()]
124
+ : Object.entries(headers ?? {});
125
+ const found = [];
126
+ for (const [key, value] of pairs) {
127
+ if (typeof key === 'string' && key.toLowerCase() === name) found.push(...(Array.isArray(value) ? value : [value]));
128
+ }
129
+ return found.map((v) => (typeof v === 'string' ? v : ''));
130
+ }
131
+
132
+ function isLoopback(address) {
133
+ if (address === 'unix') return true;
134
+ if (typeof address !== 'string') return false;
135
+ const ip = address.split('%', 1)[0];
136
+ const family = isIP(ip);
137
+ if (family === 4) return ip.split('.')[0] === '127';
138
+ if (family !== 6) return false;
139
+ const host = new URL(`http://[${ip}]/`).hostname; // the canonical compressed form: [::1], [::ffff:7f00:1]
140
+ return host === '[::1]' || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host);
141
+ }
142
+
143
+ const refuse = (code, login = null, groups = []) => ({ allowed: false, status: REFUSALS[code], code, login, groups });
144
+
145
+ function bearer(headers) {
146
+ const values = headerValues(headers, 'authorization');
147
+ if (values.length !== 1) return null;
148
+ const text = values[0].trim();
149
+ const space = text.indexOf(' ');
150
+ if (space < 0) return null;
151
+ const scheme = text.slice(0, space);
152
+ const rest = text.slice(space + 1).trim();
153
+ return scheme.toLowerCase() === 'bearer' && rest && !rest.includes(' ') ? rest : null;
154
+ }
155
+
156
+ function sameDigest(a, b) {
157
+ const x = Buffer.from(a, 'utf8');
158
+ const y = Buffer.from(b, 'utf8');
159
+ return x.length === y.length && timingSafeEqual(x, y);
160
+ }
161
+
162
+ /**
163
+ * Decide one request. `listener` is which of the agent's listeners took it (`serve` for serve's own); `peer` the
164
+ * connecting address (`unix` for a Unix socket); `perimeter` and `keys` as loadPerimeter and loadKeys answer;
165
+ * `surface` `dashboard` or `mcp`; `target` the dashboard's or the capability's name. Answers
166
+ * {allowed, status, code, login, groups}: status 200 with code `ok`, else 401 or 403 with the refusal's code.
167
+ */
168
+ export function authorize({ listener, peer, headers, perimeter, surface, target, keys = null }) {
169
+ if (!SURFACES.includes(surface)) throw new TypeError(`surface is one of ${SURFACES.join(', ')}`);
170
+ if (listener !== LISTENER_SERVE) {
171
+ if (SERVE_HEADERS.some((h) => headerValues(headers, h).length)) return refuse('serve-header-off-serve');
172
+ throw new TypeError("authorize decides serve's listener; the Fabric listener keeps its session key");
173
+ }
174
+ if (!isLoopback(peer)) return refuse('peer-not-loopback');
175
+ const logins = headerValues(headers, IDENTITY_HEADER);
176
+ if (logins.length === 0) return refuse('identity-missing');
177
+ if (logins.length > 1) return refuse('identity-ambiguous');
178
+ let login = logins[0].trim();
179
+ if (!LOGIN.test(login)) return refuse('identity-malformed');
180
+ login = login.toLowerCase();
181
+ const groups = Object.keys(perimeter.groups).filter((n) => perimeter.groups[n].members.includes(login)).sort(pyKeySort);
182
+ if (groups.length === 0) return refuse('not-in-perimeter', login);
183
+ const dashboards = new Set(groups.flatMap((n) => perimeter.groups[n].dashboards));
184
+ const capabilities = new Set(groups.flatMap((n) => perimeter.groups[n].capabilities));
185
+ if (surface === 'dashboard') {
186
+ if (!dashboards.has(target)) return refuse('dashboard-not-granted', login, groups);
187
+ return { allowed: true, status: 200, code: 'ok', login, groups };
188
+ }
189
+ const presented = bearer(headers);
190
+ if (presented === null) return refuse('key-required', login, groups);
191
+ const digest = hashKey(presented);
192
+ const key = (keys ?? []).find((k) => sameDigest(k.sha256, digest));
193
+ if (!key) return refuse('key-unknown', login, groups);
194
+ if (key.login !== login) return refuse('key-not-yours', login, groups);
195
+ if (!key.capabilities.every((c) => capabilities.has(c))) return refuse('key-wider-than-group', login, groups);
196
+ if (!key.capabilities.includes(target)) return refuse('capability-not-granted', login, groups);
197
+ return { allowed: true, status: 200, code: 'ok', login, groups, key: key.id };
198
+ }
@@ -16,6 +16,12 @@ PROTOCOL = "fabric-service/0.1"
16
16
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
17
17
 
18
18
 
19
+ # DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
20
+ # responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
21
+ # and the board kit never enrolls a service's responder for it (FAA-244).
22
+ PRIMARY_CAPABILITY = "project.work"
23
+
24
+
19
25
  # Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
20
26
  # contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
21
27
  # off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
@@ -52,6 +52,9 @@ class BoardSession:
52
52
  timeout=timeout if timeout is not None and timeout > 0 else self.timeout,
53
53
  method=method, protocol_version=self.protocol_version if self.mcp_session else None)
54
54
  except _hub.HubError as exc:
55
+ if exc.code == "foreign-user": # FAA-252: another OS account's process holds the port; nothing was sent
56
+ raise BoardError("foreign-user", "The hub's port is held by another OS account's process; nothing was sent.",
57
+ True) from exc
55
58
  raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
56
59
  "the same idempotency key.", True) from exc
57
60
 
@@ -148,6 +148,9 @@ class Responder(SlotKeeper):
148
148
  caps = list(capabilities)
149
149
  if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
150
150
  raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
151
+ if _fs.PRIMARY_CAPABILITY in caps: # FAA-244: DEC-0140 PR-1, a session's slot, never a service's
152
+ raise BoardError("invalid_arguments", "%s is a Project's primary responder's, a session's: a service answers "
153
+ "the capabilities it declares (DEC-0140 PR-1)." % _fs.PRIMARY_CAPABILITY)
151
154
  self.hub, self.project = hub, _hub._project(project_id)
152
155
  #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
153
156
  self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
@@ -258,7 +261,7 @@ class Responder(SlotKeeper):
258
261
  status = self._status()
259
262
  if missing:
260
263
  self._emit("board.access.needed", "warning", "The operator has not let this service answer %s on this Project; ask in "
261
- "Fabric (Agents & access)." % ", ".join(missing), notify=True, episode=",".join(missing),
264
+ "Fabric (Settings → Agent access)." % ", ".join(missing), notify=True, episode=",".join(missing),
262
265
  capabilities=missing)
263
266
  self.slots = {}
264
267
  self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
@@ -430,7 +433,8 @@ class Responder(SlotKeeper):
430
433
  try:
431
434
  self._receipt("com.reply", self._call("com.reply", dict(self._reply_extra(entry), fence=self._fence(old, probe["key"]))))
432
435
  except BoardError as exc:
433
- if exc.code in ("unreachable", "session-ended", "authority_changed", "not_authorized", "not_available", "capacity_exceeded"):
436
+ if exc.code in ("unreachable", "foreign-user", "session-ended", "authority_changed", "not_authorized", "not_available",
437
+ "capacity_exceeded"): # foreign-user: nothing was sent now, so nothing is known (0.79.0 seam)
434
438
  raise
435
439
  else:
436
440
  entry["replied"] = True
@@ -549,7 +553,7 @@ class Responder(SlotKeeper):
549
553
  self.state = "not-authorized"
550
554
  self._drop_session()
551
555
  self._emit("board.access.needed", "warning", "This service may not answer requests for this Project: the operator "
552
- "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
556
+ "has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.", notify=True,
553
557
  episode="access", reason=code)
554
558
  elif code == "held":
555
559
  self.state = "paused"
@@ -564,9 +568,13 @@ class Responder(SlotKeeper):
564
568
  else:
565
569
  self.state = "down"
566
570
  # In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
567
- if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol")) and not keep:
571
+ if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol",
572
+ "foreign-user")) and not keep:
568
573
  self._drop_session()
569
- self._emit("board.down", "warning", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
574
+ # FAA-252: a port another OS account holds is said as such; nothing was sent there
575
+ self._emit("board.down", "warning", "Fabric's board port is held by another OS account's process; nothing was sent, "
576
+ "and the responder tries again with a growing pause." if code == "foreign-user" else
577
+ "Fabric's board cannot be reached; the responder tries again with a growing pause.",
570
578
  episode="down", reason=code)
571
579
  return wait
572
580
 
@@ -20,7 +20,7 @@ HOLD_RETRY_MAX_SECONDS = 900.0
20
20
  #: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
21
21
  SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
22
22
  "schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
23
- "not-a-hub", "hub-unavailable", "protocol", "not_available"])
23
+ "not-a-hub", "hub-unavailable", "protocol", "not_available", "foreign-user"])
24
24
 
25
25
  _whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
26
26
 
@@ -78,7 +78,7 @@ class SlotKeeper:
78
78
  if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
79
79
  rest = {"project": self.project, "generation": generation}
80
80
  except BoardError as exc:
81
- if exc.code in ("unreachable", "session-ended"):
81
+ if exc.code in ("unreachable", "session-ended", "foreign-user"): # foreign-user: the earlier ask is unknown
82
82
  raise
83
83
  if exc.code == "idempotency_window_expired":
84
84
  rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
45
45
  jcs,
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
+ import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
48
49
  from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
49
50
 
50
51
  REFUSAL_CODES = (
@@ -672,9 +673,21 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
672
673
  return None
673
674
 
674
675
 
675
- def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
676
+ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
677
+ """Plain http, which a key set source allows on loopback only (DEC-0154 HA-2, FAA-255): every connect, a reconnect
678
+ included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's process,
679
+ so keys another account's listener serves are never read; built the way urllib builds a connection."""
680
+
681
+ def __init__(self, host: str, port: Optional[int] = None, timeout: Any = 10.0, **kwargs: Any) -> None:
682
+ http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
683
+ self.expect_pid = None
684
+ self.vouch_pid = None # an issuer's URL is never a process this caller started (FAA-267)
685
+
686
+
687
+ def _opener(deadline: Dict[str, Any], direct: bool = False) -> urllib.request.OpenerDirector:
676
688
  """No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
677
- issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
689
+ issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13). `direct`:
690
+ a loopback source never goes through a proxy from the environment, so the owner check reads the URL's own port."""
678
691
  def watched(base: type) -> type:
679
692
  class Watched(base): # type: ignore[misc, valid-type]
680
693
  def connect(self) -> None:
@@ -695,11 +708,13 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
695
708
 
696
709
  class Http(urllib.request.HTTPHandler):
697
710
  def http_open(self, req: Any) -> Any:
698
- return self.do_open(watched(http.client.HTTPConnection), req)
711
+ return self.do_open(watched(_CheckedHTTPConnection), req) # http is loopback only (the source check)
699
712
 
700
713
  class Https(urllib.request.HTTPSHandler):
701
714
  def https_open(self, req: Any) -> Any:
702
715
  return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
716
+ if direct:
717
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
703
718
  return urllib.request.build_opener(_NoRedirect, Http, Https)
704
719
 
705
720
 
@@ -709,7 +724,8 @@ def _fetch(source: str) -> Any:
709
724
  request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
710
725
  deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
711
726
  try:
712
- with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
727
+ direct = source.lower().startswith("http://")
728
+ with _opener(deadline, direct).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
713
729
  body = b""
714
730
  while len(body) <= 65536 and not deadline["fired"]:
715
731
  chunk = response.read1(65537 - len(body))
@@ -724,6 +740,8 @@ def _fetch(source: str) -> Any:
724
740
  raise
725
741
  except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
726
742
  raise ConnectionError(type(exc).__name__) from None
743
+ except _po.ListenerRefused as exc: # FAA-255: another account's listener, or one that cannot be told
744
+ raise ConnectionError("foreign-user: %s" % exc) from None
727
745
  finally:
728
746
  if deadline["timer"] is not None:
729
747
  deadline["timer"].cancel()
@@ -18,6 +18,7 @@ from pathlib import Path as _Path # noqa: E402
18
18
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
19
19
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
20
20
  from fabric_base import (
21
+ PRIMARY_CAPABILITY,
21
22
  PROTOCOL,
22
23
  ServiceError,
23
24
  _ID,
@@ -49,7 +50,7 @@ from fabric_usage import (
49
50
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
50
51
 
51
52
 
52
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
53
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
53
54
 
54
55
 
55
56
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -628,6 +629,11 @@ def configuration_problems(doc: Any) -> List[str]:
628
629
  return problems
629
630
 
630
631
 
632
+ #: FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
633
+ _RESERVED = ("%%s: %s is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never "
634
+ "serves it as a tool or declares it." % PRIMARY_CAPABILITY)
635
+
636
+
631
637
  def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
632
638
  started_at: str, status: str, degraded: Sequence[Dict[str, str]],
633
639
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
@@ -664,6 +670,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
664
670
  problems = a2a_surface_problems(surfaces["a2a"])
665
671
  if problems:
666
672
  raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
673
+ mcp_surface = surfaces.get("mcp")
674
+ listed = mcp_surface.get("capabilities") if isinstance(mcp_surface, dict) else None
675
+ if isinstance(listed, list) and PRIMARY_CAPABILITY in listed:
676
+ raise ServiceError(_RESERVED % "surfaces.mcp.capabilities")
667
677
  doc: Dict[str, Any] = {
668
678
  "protocol": PROTOCOL,
669
679
  "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
@@ -698,6 +708,9 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
698
708
  "surfaces": surfaces})
699
709
  if findings:
700
710
  raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
711
+ if any(isinstance(a, dict) and a.get("tool") == PRIMARY_CAPABILITY
712
+ for a in extensions[ACTIONS_EXTENSION_KEY].get("actions") or []):
713
+ raise ServiceError(_RESERVED % "an action's tool")
701
714
  doc["extensions"] = dict(extensions)
702
715
  if configuration is not None:
703
716
  problems = configuration_problems(configuration)
@@ -73,8 +73,42 @@ class InstanceLock:
73
73
  #: a file is allowed there).
74
74
  WINDOWS_LOCK_OFFSET = 1 << 30
75
75
 
76
+ #: How many times acquire re-opens service.lock when the file it locked is no longer the file at its path (a
77
+ #: folder swapped under it by a data import), before it gives up with an error naming the path (FAA-293).
78
+ INODE_RETRIES = 3
79
+
76
80
  def acquire(self) -> "InstanceLock":
77
81
  ensure_private_dir(self.path.parent)
82
+ for _ in range(self.INODE_RETRIES):
83
+ if self._acquire_once():
84
+ return self
85
+ raise ServiceError("%s kept moving while it was locked (a folder swapped under the data folder?): no copy "
86
+ "holds a file that is no longer at its path; start again once the folder is settled." % self.path)
87
+
88
+ def _locked_file_is_at_path(self, fd: int) -> bool:
89
+ """The inode this copy locked is the file at the path now (FAA-293). Windows does not rename a folder holding
90
+ an open file, and its lock is a byte range on this handle, so the check is POSIX only."""
91
+ if WINDOWS:
92
+ return True
93
+ try:
94
+ here, held = os.stat(str(self.path)), os.fstat(fd)
95
+ except FileNotFoundError:
96
+ return False
97
+ return (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino)
98
+
99
+ def _let_go(self, fd: int) -> None:
100
+ try:
101
+ os.close(fd)
102
+ except OSError:
103
+ pass
104
+ name, self._name = self._name, None
105
+ if name is not None:
106
+ try:
107
+ _drop_lock_name(name)
108
+ except OSError:
109
+ pass
110
+
111
+ def _acquire_once(self) -> bool:
78
112
  fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
79
113
  try:
80
114
  if WINDOWS:
@@ -92,6 +126,9 @@ class InstanceLock:
92
126
  if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
93
127
  raise AlreadyRunning(_read_pid(self.path), self.path) from None
94
128
  raise
129
+ if not self._locked_file_is_at_path(fd): # another copy may hold the file at the path: re-open that one
130
+ os.close(fd)
131
+ return False
95
132
  try:
96
133
  self._name = _hold_lock_name(self.path)
97
134
  except BaseException:
@@ -114,8 +151,11 @@ class InstanceLock:
114
151
  except OSError: # as with the close: nothing to add to the first error
115
152
  pass
116
153
  raise
154
+ if not self._locked_file_is_at_path(fd): # swapped while the pid was written: this copy holds nothing real
155
+ self._let_go(fd)
156
+ return False
117
157
  self._fd = fd
118
- return self
158
+ return True
119
159
 
120
160
  def release(self) -> None:
121
161
  if self._fd is not None:
@@ -366,6 +366,10 @@ class Client:
366
366
  if answer is not None:
367
367
  answer["attempts"] = attempts
368
368
  return answer
369
+ if outcome == "foreign-user": # FAA-257: the fallback would go to the same refused listener
370
+ raise OpenRouterError("The base URL's port is held by another OS account's process, or its owner "
371
+ "cannot be told; nothing was sent (DEC-0154 HA-2)", kind="foreign-user",
372
+ attempts=attempts)
369
373
  if outcome in _STOP_AT_ONCE.values():
370
374
  raise OpenRouterError("OpenRouter refused the key or the request (%s)" % status, kind=outcome,
371
375
  attempts=attempts)
@@ -390,6 +394,8 @@ class Client:
390
394
  # the provider may have billed a call whose answer never arrived whole (a cut connection looks the
391
395
  # same as a refused one from here): counted, never as $0; over-counting is the safe side
392
396
  self.ledger.record(self._unmeasured(asked, task_run_id, worst))
397
+ if err.kind == "foreign-user": # nothing was sent: nothing billed, and no other model there (FAA-257)
398
+ return "foreign-user", err.status, None, None
393
399
  return ("retry" if err.kind in ("connection", "timeout") else "refused"), err.status, None, None
394
400
  status = response.status
395
401
  if status in _STOP_AT_ONCE:
@@ -22,6 +22,7 @@ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa:
22
22
  from fabric_base import ( # noqa: F401
23
23
  PROTOCOL,
24
24
  EXTENSION_KEY,
25
+ PRIMARY_CAPABILITY,
25
26
  SUPERVISOR_ENV,
26
27
  DRAIN_SECONDS,
27
28
  _ID,
@@ -314,9 +315,14 @@ from fabric_supervisors import ( # noqa: F401
314
315
  launchd_plist,
315
316
  _launchctl,
316
317
  _domain,
318
+ SYSTEM_DAEMONS,
319
+ launchd_domain,
317
320
  launchd_loaded,
318
321
  launchd_pid,
319
322
  fetch_well_known,
323
+ well_known_of,
324
+ job_well_known_of,
325
+ ForeignUserAnswer,
320
326
  _OVERRIDE_ROW,
321
327
  launchd_override,
322
328
  launchd_install,