@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.
- package/CHANGELOG.md +122 -0
- package/README.md +14 -7
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +17 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +215 -25
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
CHANGED
|
@@ -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`,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -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.
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs
CHANGED
|
@@ -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).
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs
CHANGED
|
@@ -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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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();
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs
CHANGED
|
@@ -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;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -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
|
-
|
|
494
|
-
|
|
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(
|
|
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
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -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 (
|
|
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",
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
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
|
|