@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
@@ -157,6 +157,9 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
157
157
  `auth.delegation {issuer, jwks}` is then required. With `own` or `delegation` the descriptor's
158
158
  token is not an MCP credential: a host and the probe never call `/mcp` with it as a caller.
159
159
  The kits verify them with `fabric_delegation` / `fabric-delegation.mjs` ([delegation](delegation.md)).
160
+ - `surfaces.mcp.capabilities` and an action's `tool` never name `project.work` (`PRIMARY_CAPABILITY`): DEC-0140 PR-1
161
+ reserves it for a Project's primary responder, a session, and no service serves or declares it. `build_well_known`
162
+ / `buildWellKnown` refuse it with `ServiceError` (FAA-244); a longer name such as `project.workspace` is not reserved.
160
163
 
161
164
  ## Events page: `GET /fabric/v1/events?after=<cursor>&limit=<n>`
162
165
 
@@ -212,7 +215,9 @@ Loopback is one per computer, not one per OS account. Before a client sends any
212
215
  `http://127.0.0.1:<port>` (a descriptor's token, a delegation token, a login code's request, a session cookie, a door's
213
216
  token), and before a host calls a local service healthy, it tells the owner of the server end of its own connection
214
217
  from the computer's socket table. A listener of another OS account, of root, SYSTEM or a service account, or one whose
215
- owner cannot be told, is `foreign-user`: nothing is sent and nothing it answers is read as the service's. The order of
218
+ owner cannot be told, is `foreign-user`: nothing is sent and nothing it answers is read as the service's. One exception:
219
+ a LaunchDaemon's root installer reads its own job's token-free well-known answer, proved by the job's pid, uid and exact
220
+ `127.0.0.1` bind (lifecycle.md, *The system domain*; DEC-0161 accepted in principle, proposed; FAA-288). The order of
216
221
  a local service's states is `down`, `foreign-user`, `foreign` (`FAC-SEM-009`), then the well-known `status`; the
217
222
  well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at the pin).
218
223
 
@@ -232,14 +237,14 @@ listener of this account's pid on the port counts as this account's (`CheckedHTT
232
237
  `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
233
238
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
234
239
  whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
235
- IPv4 wildcard), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
240
+ IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDaemon's), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
236
241
  the contract's ruling on vouching is CT-261.
237
242
 
238
243
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
239
244
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
240
245
  `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
241
246
  `check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
242
- is never a successful install; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
247
+ is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
243
248
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
244
249
  twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
245
250
  misread; the Python twin's `sysctl` reader takes both from one record. A service's own loopback server refusing another account's peer (HA-3) is FAA-227, and the port
@@ -79,6 +79,11 @@ watch the gate fail.
79
79
  "used before honoured" write); memory works for one process.
80
80
  - **Behind a router**: check the platform-set forwarded scheme; `Host` is preserved by common
81
81
  platforms and must equal the origin's host.
82
+ - **A provider key** the remote service needs is issued and kept in Project Observatory, pushed to the platform, and
83
+ the movement recorded with
84
+ `python3 "$(project-observatory full-path)/tools/vault.py" moved <project> <env> <NAME> --how "<what moved, where, evidence>" --at <provider>`,
85
+ so the platform's copy is known rather than invisible. Without Project Observatory, record where the key lives in
86
+ the person's own secret manager.
82
87
 
83
88
  ## Checking it
84
89
 
@@ -5,6 +5,7 @@
5
5
  - [MCP for agents](#mcp-for-agents)
6
6
  - [CLI for people and scripts](#cli-for-people-and-scripts)
7
7
  - [A2A for remote agents](#a2a-for-remote-agents)
8
+ - [Behind tailscale serve](#behind-tailscale-serve)
8
9
  - [Token](#token)
9
10
  - [Dashboard session](#dashboard-session)
10
11
  - [Headers every response carries](#headers-every-response-carries)
@@ -40,7 +41,9 @@
40
41
 
41
42
  ## CLI for people and scripts
42
43
 
43
- `<tool> service status|start|stop|restart` (launchctl on the label, then the
44
+ `<tool> service status|start|stop|restart` (launchctl on the label in the domain `launchd_domain(plist)` names; for a
45
+ LaunchDaemon start, stop and restart are root's, and a CLI without root reports the job read-only, lifecycle.md, The system
46
+ domain; then the
44
47
  well-known document from this OS account's listener, else `foreign-user`: protocol.md, The listener's owner), `<tool> dashboard` (resolve the exact registered service with the `fabric-dashboards`
45
48
  MCP, run `check_dashboard_link.py` on the action, then open its `open_link` with the host's `open` and
46
49
  `fallback=never`; never `open <url>`, `open -a Safari`, `xdg-open` or `webbrowser.open`; an installed host that
@@ -82,6 +85,45 @@ A probe cannot see whether an interface would take the descriptor's token withou
82
85
  forbids, so it reads the card only and never calls an interface: an API-key scheme in the descriptor's own
83
86
  `auth.header` is a finding, and with `Authorization` (the default) the card cannot show it.
84
87
 
88
+ ## Behind tailscale serve
89
+
90
+ An agent on a server (a VM, a teammate's machine) can reach people on the tailnet through `tailscale serve`, which
91
+ proxies HTTPS on the node's tailnet name to a loopback listener and adds the caller's `Tailscale-User-Login`. The
92
+ kit's `fabric_tailnet.py` / `fabric-tailnet.mjs` (FAA-297) turn that into a decision; the agent wires it into its own
93
+ handler.
94
+
95
+ - **A listener of serve's own.** Any process on the machine can send `Tailscale-User-Login` to a loopback port, so the
96
+ header is believed only on a listener that serve alone uses: a second loopback port, or, where the installed
97
+ `tailscale serve` can proxy to one, a Unix socket whose mode keeps other accounts out. Pass `listener="serve"` and the
98
+ connection's peer (`"unix"` for a socket). On any other listener a request carrying serve's headers is refused
99
+ (`serve-header-off-serve`); the Fabric listener keeps its session key and never reads them.
100
+ - **A perimeter generated, not kept by hand.** `load_perimeter` / `loadPerimeter` take a `tailnet-perimeter/1`
101
+ document: `generatedFrom {registry, revision, at}` (required: a file without its source is refused,
102
+ `perimeter-not-generated`) and `groups {name: {members, dashboards, capabilities}}`. The organization's generator
103
+ writes it from its access registry; the agent reloads it when the file changes.
104
+ - **A personal key for MCP.** `load_keys` / `loadKeys` take `[{id, login, sha256, capabilities}]`, the key's
105
+ `hash_key` / `hashKey` only. A call on the `mcp` surface needs `Authorization: Bearer <key>`; the key must belong to
106
+ the caller's login and its capabilities may not be wider than that login's groups.
107
+ - **The answer.** `authorize(listener=, peer=, headers=, perimeter=, surface="dashboard"|"mcp", target=, keys=)`
108
+ answers `{allowed, status, code, login, groups}` (and `key`, the key's id, on an allowed MCP call). Send the
109
+ status and the code as they are; no answer carries a key or its hash.
110
+
111
+ | Code | Status | When |
112
+ |---|---|---|
113
+ | `serve-header-off-serve` | 403 | serve's headers on a listener that is not serve's |
114
+ | `peer-not-loopback` | 403 | the serve listener was reached from another address |
115
+ | `identity-missing`, `identity-ambiguous`, `identity-malformed` | 401 | no login, two, or not a login |
116
+ | `not-in-perimeter` | 403 | the login is in no group |
117
+ | `dashboard-not-granted` | 403 | no group of the login lists the dashboard |
118
+ | `key-required`, `key-unknown` | 401 | an MCP call without a bearer key, or with one not on the list |
119
+ | `key-not-yours` | 403 | the key belongs to another login |
120
+ | `key-wider-than-group` | 403 | the key's capabilities exceed the login's groups |
121
+ | `capability-not-granted` | 403 | the key does not carry the capability called |
122
+
123
+ A tagged node (a server, not a person) gets no identity header from serve and is refused `identity-missing`; a
124
+ machine caller uses the estate's delegation tokens instead. The serve listener is an outward surface: watch it like
125
+ the A2A tunnel above.
126
+
85
127
  ## Token
86
128
 
87
129
  - Created once with `ensure_token` at first start, after the instance lock.
@@ -0,0 +1,5 @@
1
+ # When to use building-fabric-services
2
+
3
+ The skill's full trigger description, kept here so the front matter's `description` stays within the client's skill listing budget (about 350 characters a skill). Read it when choosing between this skill and a neighbour.
4
+
5
+ Use when running an agent as a Fabric service (fabric-service/0.1, its kits and probe): a local service (launchd, systemd, Windows), an ONLINE agent or dashboard on https, its dashboard link and kit (narrow panel, themes), data and cache paths, how callers reach and authenticate it, long jobs (fabric.job.*), spend report and limits, runner routes and model profiles, the estate record and delegation tokens, answering a Project's board requests, or packing and signing it. «сделай агенту дашборд», «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать на запросы с доски проекта», «расходы агента на модели», "make this agent a local service", "make an online dashboard a Fabric service", "add the well-known endpoint". NOT for a one-off script or cron job, a hosted product with no agent, the provider manifest (adapting-projects-to-fabric), a new agent's design (creating-fabric-agents), a session using Fabric or its board (working-with-fabric), or building the Dashboards app (window, tray).
@@ -833,11 +833,12 @@ class Probe:
833
833
  if sys.platform != "darwin" or not shutil.which("launchctl"):
834
834
  self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
835
835
  return
836
- out = subprocess.run(["launchctl", "print", "gui/%d/%s" % (os.getuid(), life.get("label"))], capture_output=True, text=True)
836
+ domain = fs.launchd_domain(plist_path) # a plist in /Library/LaunchDaemons is a LaunchDaemon (FAA-278)
837
+ out = subprocess.run(["launchctl", "print", "%s/%s" % (domain, life.get("label"))], capture_output=True, text=True)
837
838
  match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
838
839
  served = ((self.wk or {}).get("process") or {}).get("pid")
839
840
  if out.returncode != 0:
840
- self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded" % life.get("label"))
841
+ self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
841
842
  elif not match:
842
843
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
843
844
  else:
@@ -11,6 +11,11 @@ export const PROTOCOL = 'fabric-service/0.1';
11
11
 
12
12
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
13
13
 
14
+ // DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
15
+ // responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
16
+ // and the board kit never enrolls a service's responder for it (FAA-244).
17
+ export const PRIMARY_CAPABILITY = 'project.work';
18
+
14
19
  // Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
15
20
  // contract's service specification, Lifecycle), the same values as fabric_service.py.
16
21
  // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
@@ -30,6 +30,8 @@ export class BoardSession {
30
30
  try {
31
31
  return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
32
32
  } catch (e) {
33
+ // FAA-252: another OS account's process holds the port; nothing was sent
34
+ if (e instanceof HubError && e.code === 'foreign-user') throw new BoardError('foreign-user', 'The hub\'s port is held by another OS account\'s process; nothing was sent.', true);
33
35
  if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
34
36
  throw e;
35
37
  }
@@ -3,7 +3,7 @@
3
3
  // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_session.py. Node 20+, no dependency.
4
4
  import crypto from 'node:crypto';
5
5
  import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
6
- import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
6
+ import { PRIMARY_CAPABILITY, acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
7
7
  import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
8
8
  import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
9
9
  import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
@@ -87,6 +87,8 @@ export class Responder extends SlotKeeper {
87
87
  if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
88
88
  const caps = [...(capabilities ?? [])];
89
89
  if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
90
+ // FAA-244: DEC-0140 PR-1, a session's slot, never a service's
91
+ if (caps.includes(PRIMARY_CAPABILITY)) throw new BoardError('invalid_arguments', `${PRIMARY_CAPABILITY} is a Project's primary responder's, a session's: a service answers the capabilities it declares (DEC-0140 PR-1).`);
90
92
  Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
91
93
  this.project = normalProject(projectId);
92
94
  // FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
@@ -196,7 +198,7 @@ export class Responder extends SlotKeeper {
196
198
  this.session = session;
197
199
  const status = await this.status();
198
200
  if (missing.length) {
199
- this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Agents & access).`, { notify: true, episode: missing.join(','), capabilities: missing });
201
+ this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Settings → Agent access).`, { notify: true, episode: missing.join(','), capabilities: missing });
200
202
  }
201
203
  this.slots = {};
202
204
  await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
@@ -375,7 +377,8 @@ export class Responder extends SlotKeeper {
375
377
  try {
376
378
  this.receipt('com.reply', await this.call('com.reply', { ...this.replyExtra(entry), fence: this.fence({ ...entry, generation, attempt, digest, revision }, key) }));
377
379
  } catch (e) {
378
- if (['unreachable', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
380
+ // foreign-user: nothing was sent now, so whether the old reply was stored is unknown (0.79.0 seam reading)
381
+ if (['unreachable', 'foreign-user', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
379
382
  replied = false;
380
383
  }
381
384
  if (replied) entry.replied = true;
@@ -496,7 +499,7 @@ export class Responder extends SlotKeeper {
496
499
  if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
497
500
  this.state = 'not-authorized';
498
501
  await this.dropSession();
499
- this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Agents & access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
502
+ this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
500
503
  } else if (code === 'held') {
501
504
  this.state = 'paused';
502
505
  this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
@@ -508,8 +511,11 @@ export class Responder extends SlotKeeper {
508
511
  } else {
509
512
  this.state = 'down';
510
513
  // A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
511
- if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
512
- this.emit('board.down', 'warning', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
514
+ if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'foreign-user'].includes(code)) && !keep) await this.dropSession();
515
+ // FAA-252: a port another OS account holds is said as such; nothing was sent there
516
+ this.emit('board.down', 'warning', code === 'foreign-user'
517
+ ? 'Fabric\'s board port is held by another OS account\'s process; nothing was sent, and the responder tries again with a growing pause.'
518
+ : 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
513
519
  }
514
520
  return wait;
515
521
  }
@@ -14,7 +14,7 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
14
14
 
15
15
  /** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
16
16
  export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
- 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
17
+ 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available', 'foreign-user']));
18
18
 
19
19
  const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
20
20
 
@@ -83,7 +83,7 @@ export class SlotKeeper {
83
83
  const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
84
84
  if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
85
85
  } catch (e) {
86
- if (LOST.includes(e.code)) throw e;
86
+ if (LOST.includes(e.code) || e.code === 'foreign-user') throw e; // foreign-user: the earlier ask is unknown
87
87
  if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
88
88
  }
89
89
  }
@@ -12,9 +12,11 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import fs from 'node:fs';
15
+ import http from 'node:http';
15
16
  import net from 'node:net';
16
17
  import { hubKeyCertified } from './fabric-estate.mjs';
17
18
  import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
19
+ import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs'; // a loopback key set from this account's listener only (FAA-255)
18
20
  import { effectClosure } from './fabric-mandate.mjs'; // defined once beside the mandate that reads it too (DEC-0125)
19
21
 
20
22
  export const REFUSAL_CODES = Object.freeze([
@@ -411,8 +413,34 @@ export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').up
411
413
 
412
414
  const loopback = (host) => host === 'localhost' || (net.isIP(host.replace(/^\[|\]$/g, '')) !== 0 && (/^127\./.test(host) || host.replace(/^\[|\]$/g, '') === '::1'));
413
415
 
416
+ const KEY_SET_MAX = 65536;
417
+ const KEY_SET_AGENT = new CheckedAgent({ keepAlive: false });
418
+
419
+ // Plain http, which a key set source allows on loopback only: through a CheckedAgent, so the listener's owner is told
420
+ // before a byte is written and keys another OS account's process serves are never read (DEC-0154 HA-2, FAA-255).
421
+ function loopbackKeySet(source, agent) {
422
+ return new Promise((resolve, reject) => {
423
+ const request = http.get(source.split('#')[0], { agent, headers: { Accept: 'application/json' }, timeout: 5000 }, (response) => {
424
+ if (response.statusCode < 200 || response.statusCode > 299) { response.resume(); reject(new Error(`HTTP ${response.statusCode}`)); return; }
425
+ let text = '';
426
+ response.setEncoding('utf8');
427
+ response.on('data', (chunk) => { text += chunk; if (text.length > KEY_SET_MAX) { request.destroy(); reject(new Error('the key set is too large')); } });
428
+ response.on('end', () => { try { resolve(JSON.parse(text)); } catch (e) { reject(e); } });
429
+ response.on('error', reject);
430
+ });
431
+ request.on('timeout', () => request.destroy(new Error('the key set did not arrive within 5 s')));
432
+ request.on('error', (e) => reject(e instanceof ListenerRefused ? new Error(`foreign-user: ${e.message}`) : e));
433
+ });
434
+ }
435
+
436
+ /** Read a key set source: an https URL (fetch, no redirect), a loopback http URL (checked listener) or a file. */
437
+ export async function fetchKeySet(source, { agent = KEY_SET_AGENT } = {}) {
438
+ if (/^http:\/\//.test(source)) return loopbackKeySet(source, agent);
439
+ return defaultFetch(source);
440
+ }
441
+
414
442
  async function defaultFetch(source) {
415
- if (/^https?:\/\//.test(source)) {
443
+ if (/^https:\/\//.test(source)) {
416
444
  const response = await fetch(source, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) });
417
445
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
418
446
  if (Number(response.headers.get('content-length') ?? 0) > 65536) throw new Error('the key set is too large');
@@ -428,7 +456,7 @@ async function defaultFetch(source) {
428
456
  * `await get(kid)` answers null when no set younger than 10 minutes can be had (issuer-unavailable).
429
457
  */
430
458
  export class JwksCache {
431
- constructor(source, { fetch: fetcher = defaultFetch, clock = () => Date.now() / 1000 } = {}) {
459
+ constructor(source, { fetch: fetcher = fetchKeySet, clock = () => Date.now() / 1000 } = {}) {
432
460
  if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
433
461
  if (/^https?:\/\//.test(source)) {
434
462
  const url = new URL(source);
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
6
- import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
6
+ import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
7
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
8
8
  import { protocolsFindings } from './fabric-protocols.mjs';
9
9
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
@@ -15,7 +15,7 @@ import { ROUTES } from './fabric-usage.mjs';
15
15
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
16
16
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
17
17
 
18
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.5';
18
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.6';
19
19
 
20
20
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
21
21
 
@@ -418,6 +418,9 @@ export function configurationProblems(doc) {
418
418
  // `a2a` (DEC-0122) is surfaces.a2a, where the service serves its public Agent Card (a2aSurface builds one); passed here
419
419
  // or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before DEC-0122 refuses the
420
420
  // member, so a service carries it only once its readers are pinned at or after it.
421
+ // FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
422
+ const reserved = (where) => `${where}: ${PRIMARY_CAPABILITY} is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never serves it as a tool or declares it.`;
423
+
421
424
  export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions, a2a }) {
422
425
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
423
426
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
@@ -430,6 +433,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
430
433
  const problems = a2aSurfaceProblems(surfaces.a2a);
431
434
  if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
432
435
  }
436
+ if (Array.isArray(surfaces.mcp?.capabilities) && surfaces.mcp.capabilities.includes(PRIMARY_CAPABILITY)) throw new ServiceError(reserved('surfaces.mcp.capabilities'));
433
437
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
434
438
  const doc = {
435
439
  protocol: PROTOCOL,
@@ -461,6 +465,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
461
465
  // compared with the capabilities this document's MCP surface lists.
462
466
  const findings = actionsExtensionRules({ extensions: { [ACTIONS_EXTENSION_KEY]: extensions[ACTIONS_EXTENSION_KEY] }, surfaces });
463
467
  if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
468
+ if ((extensions[ACTIONS_EXTENSION_KEY]?.actions ?? []).some((a) => a?.tool === PRIMARY_CAPABILITY)) throw new ServiceError(reserved("an action's tool"));
464
469
  }
465
470
  doc.extensions = { ...extensions };
466
471
  }
@@ -27,6 +27,9 @@ export class AlreadyRunning extends ServiceError {
27
27
  }
28
28
 
29
29
  export class InstanceLock {
30
+ /** How many times acquire re-opens service.lock that moved under it before it gives up (FAA-293). */
31
+ static INODE_RETRIES = 3;
32
+
30
33
  constructor(dataDir) {
31
34
  this.path = path.join(dataDir, 'service.lock');
32
35
  this.fd = null;
@@ -36,27 +39,12 @@ export class InstanceLock {
36
39
  async acquire() {
37
40
  ensurePrivateDir(path.dirname(this.path));
38
41
  if (process.platform === 'darwin') {
39
- const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
40
- try {
41
- this.fd = fs.openSync(this.path, flags, 0o600);
42
- } catch (error) {
43
- if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
44
- throw error;
45
- }
46
- try {
47
- fs.ftruncateSync(this.fd, 0);
48
- fs.writeSync(this.fd, String(process.pid));
49
- fs.fsyncSync(this.fd);
50
- } catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
51
- const fd = this.fd;
52
- this.fd = null;
53
- try {
54
- fs.closeSync(fd);
55
- } catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
56
- }
57
- throw error;
42
+ // FAA-293: a folder swapped under service.lock (a data import) leaves this copy holding a file that is no longer
43
+ // at the path while another copy holds the one that is. Re-open a bounded number of times, then fail clearly.
44
+ for (let attempt = 0; attempt < InstanceLock.INODE_RETRIES; attempt += 1) {
45
+ if (this.acquireDarwinOnce()) return this;
58
46
  }
59
- return this;
47
+ throw new ServiceError(`${this.path} kept moving while it was locked (a folder swapped under the data folder?): no copy holds a file that is no longer at its path; start again once the folder is settled.`);
60
48
  }
61
49
  // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
62
50
  // with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
@@ -90,6 +78,52 @@ export class InstanceLock {
90
78
  return this;
91
79
  }
92
80
 
81
+ lockedFileIsAtPath(fd) {
82
+ let here;
83
+ try {
84
+ here = fs.statSync(this.path);
85
+ } catch (error) {
86
+ if (error.code === 'ENOENT') return false;
87
+ throw error;
88
+ }
89
+ const held = fs.fstatSync(fd);
90
+ return here.dev === held.dev && here.ino === held.ino;
91
+ }
92
+
93
+ acquireDarwinOnce() {
94
+ const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
95
+ try {
96
+ this.fd = fs.openSync(this.path, flags, 0o600);
97
+ } catch (error) {
98
+ if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
99
+ throw error;
100
+ }
101
+ if (!this.lockedFileIsAtPath(this.fd)) { // another copy may hold the file at the path: re-open that one
102
+ fs.closeSync(this.fd);
103
+ this.fd = null;
104
+ return false;
105
+ }
106
+ try {
107
+ fs.ftruncateSync(this.fd, 0);
108
+ fs.writeSync(this.fd, String(process.pid));
109
+ fs.fsyncSync(this.fd);
110
+ } catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
111
+ const fd = this.fd;
112
+ this.fd = null;
113
+ try {
114
+ fs.closeSync(fd);
115
+ } catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
116
+ }
117
+ throw error;
118
+ }
119
+ if (!this.lockedFileIsAtPath(this.fd)) { // swapped while the pid was written: this copy holds nothing real
120
+ fs.closeSync(this.fd);
121
+ this.fd = null;
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+
93
127
  release() {
94
128
  if (this.fd !== null) fs.closeSync(this.fd);
95
129
  if (this.server) this.server.close();
@@ -277,6 +277,9 @@ export class Client {
277
277
  }
278
278
  attempts.push({ model: current, status: result.status, outcome: result.outcome });
279
279
  if (result.answer) return { ...result.answer, attempts };
280
+ if (result.outcome === 'foreign-user') { // FAA-257: the fallback would go to the same refused listener
281
+ throw new OpenRouterError('The base URL\'s port is held by another OS account\'s process, or its owner cannot be told; nothing was sent (DEC-0154 HA-2)', { kind: 'foreign-user', attempts });
282
+ }
280
283
  if (Object.values(STOP_AT_ONCE).includes(result.outcome)) {
281
284
  throw new OpenRouterError(`OpenRouter refused the key or the request (${result.status})`, { kind: result.outcome, attempts });
282
285
  }
@@ -299,6 +302,7 @@ export class Client {
299
302
  // may have been billed (a cut connection looks like a refused one from here): counted, never as $0
300
303
  this.ledger.record(this.unmeasured(asked, taskRunId, worst));
301
304
  }
305
+ if (err.kind === 'foreign-user') return { outcome: 'foreign-user', status: err.status }; // nothing sent, nothing billed (FAA-257)
302
306
  return { outcome: ['connection', 'timeout'].includes(err.kind) ? 'retry' : 'refused', status: err.status };
303
307
  }
304
308
  const { status } = response;
@@ -534,7 +534,9 @@ export async function pidListens(pid, port, { run = runProgram, platform = proce
534
534
  // Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
535
535
  export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, vouchPid = null, ...ask } = {}) {
536
536
  return new Promise((resolveSocket, reject) => {
537
- const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
537
+ // autoSelectFamily: Node 18 connects to the first address only, and localhost resolves to ::1 first on Linux, so a
538
+ // listener on 127.0.0.1 was never reached; Python tries every address, as Node 20 and later do by default.
539
+ const socket = net.connect({ host, port, noDelay: true, autoSelectFamily: true }); // as a plain http.Agent's socket
538
540
  const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
539
541
  socket.once('error', (err) => { clearTimeout(timer); reject(err); });
540
542
  socket.once('connect', async () => {
@@ -66,7 +66,10 @@ export function rawValues(query) {
66
66
  if (piece === '') continue;
67
67
  const at = piece.indexOf('=');
68
68
  const name = [...new URLSearchParams(at < 0 ? piece : piece.slice(0, at)).keys()][0] ?? '';
69
- values.set(name, [...(values.get(name) ?? []), at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1))]);
69
+ const value = at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1));
70
+ const list = values.get(name);
71
+ if (list) list.push(value); // appended in place: copying the list per piece made a long query quadratic (FAA-245)
72
+ else values.set(name, [value]);
70
73
  }
71
74
  return values;
72
75
  }
@@ -5,6 +5,7 @@
5
5
  export {
6
6
  PROTOCOL,
7
7
  EXTENSION_KEY,
8
+ PRIMARY_CAPABILITY,
8
9
  SUPERVISOR_ENV,
9
10
  DRAIN_SECONDS,
10
11
  ServiceError,
@@ -177,6 +178,11 @@ export {
177
178
  taskInstall,
178
179
  taskUninstall,
179
180
  launchdPlist,
181
+ SYSTEM_DAEMONS,
182
+ launchdDomain,
183
+ wellKnownOf,
184
+ jobWellKnownOf,
185
+ ForeignUserAnswer,
180
186
  launchdLoaded,
181
187
  launchdPid,
182
188
  launchdOverride,