@passioncode-ai/fabric-agent-adapter 0.78.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 (57) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +14 -7
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +6 -1
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +17 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -10
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +215 -25
  46. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -5
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  50. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  52. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -9
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  56. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  57. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -81,7 +81,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
81
81
  | Receipts | every status-200 answer records one, and so does an attempt that may have been billed without an answer to read (a timeout, a cut or refused connection, a 200 over the size cap or not JSON: no tokens, `costUsd: null`, `tokensMeasured: false`; over-counting a refused connection is the safe side): the served model, the provider's tokens (cache reads and writes kept apart), `costUsd` from `usage.cost`, else from the price list (`costBasis: price-list`), else `null` with `tokensMeasured: false`; `route: key`, the client's `project_id` and the call's `task_run_id`. A request the provider answered with a 4xx or 5xx records nothing: it was not billed |
82
82
  | What is an answer | `finish_reason` `stop` or `tool_calls` with content or tool calls; `error` is retried on the same model; `length` (the budget ran out, reasoning included), `content_filter` and empty content go to the fallback |
83
83
  | Retries | 408, 425, 429, 500, 502, 503, 504 and connection errors on the same model, `retries` times (default 1), `Retry-After` honoured, within one deadline (`deadline_s` 120) for the whole call |
84
- | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model |
84
+ | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model; a loopback `base_url` another OS account's process holds (kind `foreign-user`, nothing sent, nothing counted): the fallback would reach the same listener (FAA-257) |
85
85
  | The fallback | required, of another vendor (the part before `/` differs) |
86
86
  | Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
87
87
  | What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
@@ -90,7 +90,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
90
90
  | Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
91
91
 
92
92
  `OpenRouterError.attempts` lists every attempt as `{model, status, outcome}` (`answered`, `retry`, `error`,
93
- `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`); nothing in it holds the key or the
93
+ `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`, `foreign-user`); nothing in it holds the key or the
94
94
  prompt. `totals(receipts)` gives calls, priced spend and unpriced calls for a dashboard line. Choosing the models
95
95
  themselves: creating-fabric-agents, `references/models-and-providers.md`.
96
96
 
@@ -59,7 +59,7 @@ Never edit the copied files: the page's own rules go in its own stylesheet, cons
59
59
  | Grids | `.fd-grid` (cards, 320 px columns), `.fd-grid--tiles` (160 px), `.fd-grid--wide` (480 px); `--fd-col` sets your own |
60
60
  | Wide content | `<div class="fd-scroll" data-fd-label="Jobs table"><table class="fd-table">…</table></div>`, `<pre class="fd-scroll">` |
61
61
  | Atoms | `.fd-card`, `.fd-tile`, `.fd-chip` + `.fd-dot` + `.fd-s-ok\|warn\|danger\|info`, `.fd-btn`, `.fd-btn--primary`, `.fd-input`, `.fd-select`, `.fd-banner`, `.fd-empty`, `.fd-pairs` |
62
- | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3) |
62
+ | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3). Since 1.1.6 a resize refits in the next animation frame and only when the nav's width changed, and the chrome's `--fd-chrome-h` and `--fd-chrome-top` are written the same way and only when they change, so no observer resizes what it watches inside its own callback (WebKit's "ResizeObserver loop completed with undelivered notifications" at 390 px, seen on a service page) |
63
63
  | Own appearance controls | `<select data-fd-theme-picker>`, `<select data-fd-scheme-picker>` (the script fills them; hidden while the host owns appearance) |
64
64
 
65
65
  Tokens are the workbench layer (`--bg`, `--panel`, `--panel-2`, `--ink`, `--muted`, `--border`,
@@ -125,11 +125,43 @@ its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
125
125
  the transport. Unknown or failed host status produces no executable local action.
126
126
  A separately labelled copyable link for diagnosis is not an open action.
127
127
 
128
- Add this check to the generated project's dashboard completion/notification
129
- renderer before delivery. A raw-HTTP primary action with an available host is the
130
- negative fixture; a valid native action is the positive one. Keep diagnostics
131
- separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
132
- or instructions alone do not prove all final answers from every agent are filtered.
128
+ The gate is **agent-side**: the agent runs it from this skill's directory before it
129
+ delivers an action. It is never copied into the generated project and never run by
130
+ that project's CI: the adapter's versioned plugin folder has no stable path there, so
131
+ its result in a project's pipeline would always be NOT_RUN. A raw-HTTP primary action
132
+ with an available host is the gate's negative fixture; a valid native action is the
133
+ positive one. Keep diagnostics separate. This gate checks structured actions, not
134
+ arbitrary prose: a CLI invocation or instructions alone do not prove all final answers
135
+ from every agent are filtered.
136
+
137
+ The generated project pins the same negative fixture in its own test suite, without
138
+ calling the gate: a test that renders its completion and notification output with an
139
+ available host and fails when a raw loopback `http_url` or a legacy `open?service=`
140
+ link appears in it. The snippet is self-contained; `render` stands for the project's
141
+ own renderer:
142
+
143
+ ```python
144
+ import re
145
+ import unittest
146
+
147
+ RAW_LOOPBACK = re.compile(r"https?://(?:127\.0\.0\.1|localhost|\[::1\])")
148
+ LEGACY = "fabric-dashboards://open?service="
149
+ RESOLVED = {"service": "example-agent.default",
150
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
151
+ "http_url": "http://127.0.0.1:47195/dashboard"}
152
+
153
+
154
+ def render(resolved, host_state): # replace with the project's completion/notification renderer
155
+ raise NotImplementedError
156
+
157
+
158
+ class RenderedDashboardLinks(unittest.TestCase):
159
+ def test_rendered_output_has_no_raw_loopback_or_legacy_link(self):
160
+ out = render(RESOLVED, host_state="available")
161
+ self.assertIn(RESOLVED["open_link"], out)
162
+ self.assertIsNone(RAW_LOOPBACK.search(out), out)
163
+ self.assertNotIn(LEGACY, out)
164
+ ```
133
165
 
134
166
  ## Degradation
135
167
 
@@ -127,7 +127,10 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
127
127
 
128
128
  `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
129
129
  a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
130
- `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
130
+ `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. On loopback
131
+ http it tells the listener's owner from the socket table before a byte is sent and reads no key set another OS
132
+ account's process serves (DEC-0154 HA-2, `fabric_peer_owner`; the read fails `foreign-user`, so the set stays unread,
133
+ FAA-255); no proxy from the environment is used there. Where the socket table hides other processes (macOS 27.2 with an ad-hoc-signed Homebrew Python, FAA-267), the owner cannot be told, so a loopback key set fails `foreign-user` and every delegated call is refused `issuer-unavailable`: run the service on Apple's `/usr/bin/python3`, or give the verifier the key set as a file. The
131
134
  Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
132
135
  reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
133
136
  and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
@@ -51,7 +51,12 @@ carry a credential. Only `doctor` and `update` are commands a host may run.
51
51
  **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
52
52
  authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's,
53
53
  on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers; a
54
- LaunchDaemon's root installer reads it as *The system domain* says).
54
+ LaunchDaemon's root installer reads it as *The system domain* says). `well_known_of(origin)` / `wellKnownOf(origin)` read
55
+ it so a status tells HA-2's order apart: None / null when nothing answers (`down`), a `ForeignUserAnswer` whose
56
+ `reason` starts `foreign-user` or `table-restricted`, else the document; `fetch_well_known` keeps answering None for
57
+ both (FAA-259). Every loopback spelling of an origin (`127.0.0.1`, another `127/8` address, `localhost`, `[::1]`) is read
58
+ on a checked connection (FAA-257). `launchd_install` takes an injected reader, `fetch_well_known=` and
59
+ `fetch_job_well_known=`, as the Node `launchdInstall` takes `fetchWellKnown` and `fetchJobWellKnown`.
55
60
  `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
56
61
  full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
57
62
  that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
@@ -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
 
@@ -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,
@@ -179,6 +180,7 @@ export {
179
180
  launchdPlist,
180
181
  SYSTEM_DAEMONS,
181
182
  launchdDomain,
183
+ wellKnownOf,
182
184
  jobWellKnownOf,
183
185
  ForeignUserAnswer,
184
186
  launchdLoaded,
@@ -490,8 +490,20 @@ export class ForeignUserAnswer {
490
490
  // The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
491
491
  // (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
492
492
  // account's or its owner cannot be told.
493
- export async function wellKnownOf(origin, { vouchPid = null } = {}) {
494
- const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
493
+ // Every loopback spelling (127.0.0.1, another 127/8 address, localhost, [::1]) goes on a checked connection, never the
494
+ // unchecked way (FAA-257): the host, without brackets, and the port, or null.
495
+ function loopbackOrigin(origin) {
496
+ let url;
497
+ try { url = new URL(String(origin)); } catch { return null; }
498
+ const host = url.hostname.replace(/^\[|\]$/g, '');
499
+ const loop = host === 'localhost' || host === '::1' || (net.isIPv4(host) && host.startsWith('127.'));
500
+ if (url.protocol !== 'http:' || !loop) return null;
501
+ // new URL drops a default port: http://127.0.0.1:80 has port '', read as 80 as Python's urlsplit reads it (0.79.0 seam)
502
+ return { host, port: Number(url.port || 80) };
503
+ }
504
+
505
+ export async function wellKnownOf(origin, { vouchPid = null, ask = undefined } = {}) {
506
+ const m = loopbackOrigin(origin);
495
507
  if (!m) {
496
508
  try {
497
509
  const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
@@ -502,14 +514,14 @@ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
502
514
  }
503
515
  // A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
504
516
  // answer, so a poll leaves no connection open behind it.
505
- return loopbackWellKnown(Number(m[1]), new CheckedAgent({ keepAlive: false, vouchPid }));
517
+ return loopbackWellKnown(m.port, new CheckedAgent({ keepAlive: false, vouchPid, ...(ask ? { ask } : {}) }), m.host);
506
518
  }
507
519
 
508
520
  // The well-known answer at 127.0.0.1:<port> over `agent`, whose connections check their server end: null when nothing
509
521
  // answers or the answer is not JSON, a ForeignUserAnswer when the agent refused the listener.
510
- function loopbackWellKnown(port, agent) {
522
+ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
511
523
  return new Promise((resolve) => {
512
- const req = http.request({ host: '127.0.0.1', port, path: '/.well-known/fabric-service', agent,
524
+ const req = http.request({ host, port, path: '/.well-known/fabric-service', agent,
513
525
  timeout: 2000 }, (res) => {
514
526
  const chunks = [];
515
527
  res.on('data', (c) => chunks.push(c));
@@ -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