@passioncode-ai/fabric-agent-adapter 0.73.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/README.md +11 -7
- package/SECURITY.md +17 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +3 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +16 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +33 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +37 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +214 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +6 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +380 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +8 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +3 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1: wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `18f8d48762edc5bfb1433657cbfe1c16196df1a6`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
@@ -36,6 +36,7 @@ this page is the working summary. Extension key:
|
|
|
36
36
|
- Well-known document
|
|
37
37
|
- Events page
|
|
38
38
|
- Login code and the operator session
|
|
39
|
+
- The listener's owner (DEC-0154 HA-2)
|
|
39
40
|
- Semantic rules a host applies
|
|
40
41
|
|
|
41
42
|
## Descriptor: written by the installer
|
|
@@ -48,9 +49,9 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
48
49
|
| Field | Rule |
|
|
49
50
|
|---|---|
|
|
50
51
|
| `protocol` | `"fabric-service/0.1"` |
|
|
51
|
-
| `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique
|
|
52
|
+
| `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique within one OS account's services directory (DEC-0154) |
|
|
52
53
|
| `name`, `summary` | ≤ 80 and ≤ 200 characters |
|
|
53
|
-
| `origin` | `http://127.0.0.1:<port>`; the port is a
|
|
54
|
+
| `origin` | `http://127.0.0.1:<port>`; the port is a declaration no other descriptor in this account's directory makes (`FAC-SEM-010`), kept free on the computer by HA-1, whose claim the kit does not write yet (FAA-225). Optional for an app's descriptor only (`manager: "person"`, DEC-0069): its live origin and process id are in the app's own liveness file, and a descriptor without an origin claims no port (`FAC-SEM-010`) |
|
|
54
55
|
| `auth` | `tokenFile` (0600; on Windows a protected ACL: owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
|
|
55
56
|
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
|
|
56
57
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
@@ -61,7 +62,7 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
61
62
|
| `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none`: a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
|
|
62
63
|
| `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed`, `personal` or `processor-hosted` (an organization's cloud a processor runs under a processing agreement, DEC-0099); `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
|
|
63
64
|
|
|
64
|
-
A local descriptor MAY say where a collector
|
|
65
|
+
A local descriptor MAY say where a collector in the same OS account reads its daily operations digests, under
|
|
65
66
|
`https://fabric.passioncode.ai/agent-contract/extensions/ops-digest/0.1` (`fabric-ops-digest/0.1`, DEC-0153, OD-9,
|
|
66
67
|
FAC-SEM-168): 1 to 8 `{dir, source}`, `dir` a folder inside `paths.data`. A remote descriptor carries none, and the key
|
|
67
68
|
is read nowhere else. The kits know the revision and neither write nor check the declaration yet (FAA-206).
|
|
@@ -205,6 +206,34 @@ The session it starts ends 12 h after its last use, 7 days after sign-in, on log
|
|
|
205
206
|
descriptor token rotates (DEC-0039); a service may shorten either bound, never lengthen one. An
|
|
206
207
|
ended session answers `401` on the dashboard page.
|
|
207
208
|
|
|
209
|
+
## The listener's owner (DEC-0154 HA-2)
|
|
210
|
+
|
|
211
|
+
Loopback is one per computer, not one per OS account. Before a client sends any credential to
|
|
212
|
+
`http://127.0.0.1:<port>` (a descriptor's token, a delegation token, a login code's request, a session cookie, a door's
|
|
213
|
+
token), and before a host calls a local service healthy, it tells the owner of the server end of its own connection
|
|
214
|
+
from the computer's socket table. A listener of another OS account, of root, SYSTEM or a service account, or one whose
|
|
215
|
+
owner cannot be told, is `foreign-user`: nothing is sent and nothing it answers is read as the service's. The order of
|
|
216
|
+
a local service's states is `down`, `foreign-user`, `foreign` (`FAC-SEM-009`), then the well-known `status`; the
|
|
217
|
+
well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at the pin).
|
|
218
|
+
|
|
219
|
+
The kit's module is `fabric_peer_owner` / `fabric-peer-owner.mjs` (layer L0, [kit modules](kit-modules.md)): `listener(port)`,
|
|
220
|
+
`server_end(sock)` / `serverEnd(socket)`, `peer`, and a connection checked before a byte is written
|
|
221
|
+
(`CheckedHTTPConnection`; Node `checkedSocket`, `CheckedAgent`). Python reads macOS through `sysctl
|
|
222
|
+
net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`), Linux through `/proc/net/tcp`
|
|
223
|
+
and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's token SID; Node reads `netstat -anv` and `ps`
|
|
224
|
+
on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
|
|
225
|
+
`this-account` refuses, `unknown` included.
|
|
226
|
+
|
|
227
|
+
Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
|
|
228
|
+
a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
|
|
229
|
+
`fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
|
|
230
|
+
`check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
|
|
231
|
+
is never a successful install; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
|
|
232
|
+
session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
|
|
233
|
+
twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
|
|
234
|
+
misread; the Python twin's `sysctl` reader takes both from one record. A service's own loopback server refusing another account's peer (HA-3) is FAA-227, and the port
|
|
235
|
+
claim file (HA-1) waits on the contract's CT-228.
|
|
236
|
+
|
|
208
237
|
## Semantic rules a host applies
|
|
209
238
|
|
|
210
239
|
| Code | Rule |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md
CHANGED
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
(`auth.delegation-declared`). The kits verify the tokens: put `delegation_gate` /
|
|
28
28
|
`delegationGate` in front of the tools (see [the delegation reference](delegation.md)).
|
|
29
29
|
- Register it into a client config with the service's own command
|
|
30
|
-
(`<tool> mcp-register`), which
|
|
31
|
-
|
|
30
|
+
(`<tool> mcp-register`), which first establishes that the port's listener is this OS account's (the contract's
|
|
31
|
+
DEC-0154 HA-2: `fabric_peer_owner.server_end` on a connection of its own, as `check_service.py`'s `listener.owner`
|
|
32
|
+
does; otherwise `foreign-user` and nothing is written), then writes
|
|
33
|
+
`{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}` atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
|
|
32
34
|
goes in `headers`, never in the URL: every log that records a URL prints its query
|
|
33
35
|
string. An HTTP registration starts nothing per session: every client session talks to the
|
|
34
36
|
one resident service (LC-19). A stdio server, by contrast, is a process per session; keep
|
|
@@ -39,7 +41,7 @@
|
|
|
39
41
|
## CLI for people and scripts
|
|
40
42
|
|
|
41
43
|
`<tool> service status|start|stop|restart` (launchctl on the label, then the
|
|
42
|
-
well-known document), `<tool> dashboard` (resolve the exact registered service with the `fabric-dashboards`
|
|
44
|
+
well-known document from this OS account's listener, else `foreign-user`: protocol.md, The listener's owner), `<tool> dashboard` (resolve the exact registered service with the `fabric-dashboards`
|
|
43
45
|
MCP, run `check_dashboard_link.py` on the action, then open its `open_link` with the host's `open` and
|
|
44
46
|
`fallback=never`; never `open <url>`, `open -a Safari`, `xdg-open` or `webbrowser.open`; an installed host that
|
|
45
47
|
failed is reported, never replaced by a browser; only a confirmed `not_installed` host permits an explicit browser
|
|
@@ -85,7 +87,7 @@ forbids, so it reads the card only and never calls an interface: an API-key sche
|
|
|
85
87
|
- Created once with `ensure_token` at first start, after the instance lock.
|
|
86
88
|
- Stored at the descriptor's `auth.tokenFile`, 0600, owner-checked, never a symlink.
|
|
87
89
|
- Compared in constant time (`token_matches`).
|
|
88
|
-
- Rotation is an operator act: write a new file, restart, re-run `mcp-register
|
|
90
|
+
- Rotation is an operator act: write a new file, restart, re-run `mcp-register` (which checks the listener's owner again).
|
|
89
91
|
- Same-user processes can read it. The protocol defends against web pages and
|
|
90
92
|
mistakes, not against hostile code running as the operator; say so in SECURITY.md.
|
|
91
93
|
|
|
@@ -66,10 +66,12 @@ left out.
|
|
|
66
66
|
it, and the host only shows it.
|
|
67
67
|
|
|
68
68
|
**Every limit (DEC-0027).** Pass `budgets` to list every spending limit the service applies,
|
|
69
|
-
enforced or not, so the spend view shows all of them; the operator asked for that on 2026-10-07
|
|
69
|
+
enforced or not, so the spend view shows all of them; the operator asked for that on 2026-10-07. In Python
|
|
70
|
+
`ledger.report` takes no `budgets`, so call `usage_report` over the ledger's receipts (Node's `ledger.report` passes
|
|
71
|
+
them through):
|
|
70
72
|
|
|
71
73
|
```python
|
|
72
|
-
ledger.
|
|
74
|
+
fabric_usage.usage_report(ledger.receipts(), service_id=ID, instance=INSTANCE, budget={"period": "month", "limitUsd": 100}, budgets=[
|
|
73
75
|
{"id": "machine.monthly", "scope": "machine", "kind": "monthly", "period": "month", "limitUsd": 100, "enforced": True},
|
|
74
76
|
{"id": "project.demo.daily", "scope": "project", "subject": "demo", "kind": "daily", "windowSeconds": 86400,
|
|
75
77
|
"limitUsd": 5, "spentUsd": 0.31, "enforced": True},
|
|
@@ -94,7 +94,24 @@ async function serviceTarget(spec, dir) {
|
|
|
94
94
|
const header = auth.header || 'Authorization';
|
|
95
95
|
const value = (auth.scheme || 'Bearer') === 'Bearer' ? `Bearer ${token}` : token;
|
|
96
96
|
const remote = d.placement === 'remote';
|
|
97
|
-
|
|
97
|
+
// Only an https origin goes through fetch. Every other one, a local origin above all, is asked only on a connection
|
|
98
|
+
// whose server end is this OS account's process (DEC-0154 HA-2): fabric-http (beside this file in the kit) checks it
|
|
99
|
+
// before the token is sent, refuses with kind foreign-user, and refuses plain http to another host. The path follows
|
|
100
|
+
// the URL, never the descriptor's placement (0.74.0 unit reading 7: a "remote" descriptor naming 127.0.0.1 sent its
|
|
101
|
+
// token unchecked). A copy of this file alone cannot tell the owner, so it sends nothing to such an origin.
|
|
102
|
+
const viaKit = !/^https:\/\//i.test(origin);
|
|
103
|
+
let kitRequest = null;
|
|
104
|
+
if (viaKit) {
|
|
105
|
+
kitRequest = (await import('./fabric-http.mjs').catch(() => null))?.request;
|
|
106
|
+
if (!kitRequest) throw new Error('fabric-http.mjs and fabric-peer-owner.mjs are not beside this file: the local listener\'s owner cannot be told, so no token is sent (DEC-0154 HA-2)');
|
|
107
|
+
}
|
|
108
|
+
const call = !viaKit
|
|
109
|
+
? async (method, url, headers) => { const r = await fetch(url, { method, headers }); return { status: r.status, ok: r.ok, json: () => r.json() }; }
|
|
110
|
+
: async (method, url, headers) => {
|
|
111
|
+
const r = await kitRequest(method, url, { headers, retries: 0, deadlineS: 10 });
|
|
112
|
+
return { status: r.status, ok: r.status >= 200 && r.status < 300, json: async () => r.json() };
|
|
113
|
+
};
|
|
114
|
+
const wkRes = await call('GET', `${origin}/.well-known/fabric-service`, remote && token ? { [header]: value } : {});
|
|
98
115
|
if (!wkRes.ok) throw new Error(`well-known document answered HTTP ${wkRes.status}`);
|
|
99
116
|
const wk = await wkRes.json();
|
|
100
117
|
const dashboard = wk.surfaces?.dashboard;
|
|
@@ -103,7 +120,7 @@ async function serviceTarget(spec, dir) {
|
|
|
103
120
|
let url = origin + dashboard.path;
|
|
104
121
|
if (dashboard.login) {
|
|
105
122
|
if (!token) throw new Error(`no readable token file for ${id}.${instance}`);
|
|
106
|
-
const res = await
|
|
123
|
+
const res = await call('POST', `${origin}/fabric/v1/login-code`, { [header]: value });
|
|
107
124
|
if (!res.ok) throw new Error(`login-code answered HTTP ${res.status}`);
|
|
108
125
|
url = origin + (await res.json()).url;
|
|
109
126
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
--board-kit PATH (board.responder: the service's own fabric_board.py or fabric-board.mjs)
|
|
9
9
|
--repo PATH (dashboard.opens-in-host: the service's checkout)
|
|
10
10
|
|
|
11
|
+
A local placement is read only when this OS account's process holds its port (DEC-0154 HA-2): `listener.owner`
|
|
12
|
+
comes before every rule that reads the port, from the computer's socket table (fabric_peer_owner). Nothing listening is NOT_RUN (`down`, and
|
|
13
|
+
`well-known.answers` fails as before); another OS account's, root's or SYSTEM's listener, or one whose owner cannot be
|
|
14
|
+
told, FAILs as `foreign-user`, and then no rule reads the port and no credential is sent; one `port.rules` NOT_RUN
|
|
15
|
+
line names the rules not read (as it does when the well-known document gives nothing to read). Every local
|
|
16
|
+
connection, a reconnect included, is checked again before a byte goes out on it.
|
|
17
|
+
|
|
11
18
|
A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
|
|
12
19
|
TLS with the certificate verified: the well-known document must refuse a request without the
|
|
13
20
|
token (401 with none of the service's data: an empty body, or an identity proxy's page, DEC-0109), the guards must refuse a foreign Host/Origin and cross-site requests,
|
|
@@ -72,6 +79,8 @@ import fabric_service as fs # noqa: E402
|
|
|
72
79
|
import fabric_connections as fcn # noqa: E402
|
|
73
80
|
from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
|
|
74
81
|
import fabric_interop as fi # noqa: E402
|
|
82
|
+
import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2: the listener's owner before any credential
|
|
83
|
+
from check_service_rules import ForeignUser, PORT_RULES_UNREAD, listener_owner_verdict # noqa: E402 - DEC-0154 HA-2
|
|
75
84
|
|
|
76
85
|
Result = Dict[str, str]
|
|
77
86
|
|
|
@@ -153,13 +162,16 @@ class Probe:
|
|
|
153
162
|
conn = http.client.HTTPSConnection(self.host_name, self.port, context=context, timeout=8)
|
|
154
163
|
base = {"Host": self.netloc}
|
|
155
164
|
else:
|
|
156
|
-
|
|
165
|
+
# DEC-0154 HA-2: every local connection, a reconnect included, is checked before a byte is sent on it.
|
|
166
|
+
conn = po.CheckedHTTPConnection("127.0.0.1", self.port, timeout=5)
|
|
157
167
|
base = {"Host": "127.0.0.1:%d" % self.port}
|
|
158
168
|
base.update(headers or {})
|
|
159
169
|
try:
|
|
160
170
|
conn.request(method, path, body=body, headers=base)
|
|
161
171
|
resp = conn.getresponse()
|
|
162
172
|
return resp.status, merge_headers(resp.getheaders()), resp.read()
|
|
173
|
+
except po.ListenerRefused as exc:
|
|
174
|
+
raise ForeignUser("foreign-user: %s" % exc) from None
|
|
163
175
|
finally:
|
|
164
176
|
conn.close()
|
|
165
177
|
|
|
@@ -168,6 +180,14 @@ class Probe:
|
|
|
168
180
|
name, value = fs.token_header(self.d.get("auth"), self.token or "")
|
|
169
181
|
return {name: value}
|
|
170
182
|
|
|
183
|
+
# the listener's owner (DEC-0154 HA-2) -------------------------------
|
|
184
|
+
def listener_rule(self) -> bool:
|
|
185
|
+
"""DEC-0154 HA-2, `down` then `foreign-user` then the answer (check_service_rules.listener_owner_verdict). False
|
|
186
|
+
when the port must not be read (`foreign-user`)."""
|
|
187
|
+
verdict, evidence = listener_owner_verdict(self.port)
|
|
188
|
+
self.add("listener.owner", verdict, evidence)
|
|
189
|
+
return verdict != "FAIL"
|
|
190
|
+
|
|
171
191
|
# descriptor ------------------------------------------------------------
|
|
172
192
|
def descriptor_rules(self) -> None:
|
|
173
193
|
problems = fs.validate_descriptor(self.d)
|
|
@@ -200,7 +220,7 @@ class Probe:
|
|
|
200
220
|
elif "origin" not in self.d:
|
|
201
221
|
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
202
222
|
else:
|
|
203
|
-
unique = "port %d is
|
|
223
|
+
unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % self.port
|
|
204
224
|
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
205
225
|
# FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
|
|
206
226
|
# is named here (NOT_RUN is no pass) until its owner adds it.
|
|
@@ -261,6 +281,12 @@ class Probe:
|
|
|
261
281
|
self.add_no_token(no_token, None)
|
|
262
282
|
self.add("well-known.answers", "FAIL", "body is not JSON")
|
|
263
283
|
return
|
|
284
|
+
if not isinstance(self.wk, dict):
|
|
285
|
+
self.wk = None
|
|
286
|
+
if self.remote:
|
|
287
|
+
self.add_no_token(no_token, None)
|
|
288
|
+
self.add("well-known.answers", "FAIL", "body is not a JSON object")
|
|
289
|
+
return
|
|
264
290
|
if self.remote:
|
|
265
291
|
self.add_no_token(no_token, self.wk)
|
|
266
292
|
self.add("well-known.answers", "PASS", "HTTP 200")
|
|
@@ -899,8 +925,16 @@ class Probe:
|
|
|
899
925
|
return self.results
|
|
900
926
|
if not self.port:
|
|
901
927
|
return self.results
|
|
928
|
+
if not self.remote and not self.listener_rule():
|
|
929
|
+
self.add("port.rules", "NOT_RUN", "not read: " + PORT_RULES_UNREAD + ", because the listener is not this OS "
|
|
930
|
+
"account's (listener.owner FAIL, DEC-0154 HA-2)")
|
|
931
|
+
self.lifecycle_rules()
|
|
932
|
+
return self.results
|
|
902
933
|
self.well_known_rules()
|
|
903
|
-
if self.wk is
|
|
934
|
+
if self.wk is None:
|
|
935
|
+
self.add("port.rules", "NOT_RUN", "not read: " + PORT_RULES_UNREAD + ", because well-known.answers above "
|
|
936
|
+
"got no document to read")
|
|
937
|
+
else:
|
|
904
938
|
self.network_rules()
|
|
905
939
|
self.auth_rules()
|
|
906
940
|
self.usage_rules()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -20,6 +20,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
20
20
|
import fabric_service as fs # noqa: E402 - beside this file
|
|
21
21
|
from fabric_http import redact_url # noqa: E402
|
|
22
22
|
from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
|
|
23
|
+
import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
|
|
24
|
+
import socket # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# The rules that ask the service's port, named on one NOT_RUN line when none of them can be read (0.74.0 product
|
|
28
|
+
# reading 8): a probe names what it could not check.
|
|
29
|
+
PORT_RULES_UNREAD = ("every rule that reads the port (well-known, network, auth, usage, A2A, dashboard, login, "
|
|
30
|
+
"interop, channel-agent, board)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ForeignUser(ConnectionError):
|
|
34
|
+
"""DEC-0154 HA-2: the server end of a local connection is not this OS account's process (`foreign-user`); nothing
|
|
35
|
+
was sent on it. A ConnectionError, so every rule that reads the port reports it as no answer."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def listener_owner_verdict(port: int) -> Tuple[str, str]:
|
|
39
|
+
"""`listener.owner` for a local service: (verdict, evidence). `down`, then `foreign-user`, then everything the
|
|
40
|
+
listener answers: a local service is read only when this OS account's process holds its port. The owner is the
|
|
41
|
+
server end of a connection of the probe's own, on which nothing is sent (HA-2's source: the client's own connection;
|
|
42
|
+
two sockets of one account on one port, as SO_REUSEPORT workers hold, read as that account)."""
|
|
43
|
+
where = "127.0.0.1:%d" % port
|
|
44
|
+
try:
|
|
45
|
+
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
|
|
46
|
+
except ConnectionRefusedError:
|
|
47
|
+
return "NOT_RUN", "nothing listens on %s (down)" % where
|
|
48
|
+
except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
|
|
49
|
+
return "NOT_RUN", "could not connect to %s to tell its owner (%s)" % (where, type(exc).__name__)
|
|
50
|
+
try:
|
|
51
|
+
owner = po.server_end(sock)
|
|
52
|
+
finally:
|
|
53
|
+
sock.close()
|
|
54
|
+
if owner.kind == po.THIS_ACCOUNT:
|
|
55
|
+
return "PASS", "%s is held by this OS account's process (pid %s, read by %s)" % (
|
|
56
|
+
where, owner.pid if owner.pid is not None else "not shown", owner.method)
|
|
57
|
+
why = {po.ANOTHER_ACCOUNT: "another OS account's process", po.SYSTEM: "a system account's process"}.get(
|
|
58
|
+
owner.kind, "a process whose owner cannot be told (%s)" % owner.reason)
|
|
59
|
+
return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
|
|
60
|
+
"as this service's" % (where, why, owner.method))
|
|
23
61
|
|
|
24
62
|
_LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
|
|
25
63
|
_SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
// is safe: an idempotent method or an `Idempotency-Key` header; any other request only on 429. The wait before retry n
|
|
10
10
|
// (from 0) is min(maxBackoffS, backoffBaseS * 2**n) * (0.5 + rand()); a `Retry-After` replaces it up to
|
|
11
11
|
// maxRetryAfterS. A wait past the deadline is not taken. Plain http is refused unless the host is loopback or
|
|
12
|
-
// `insecure`. An error names the method and the redacted URL, never a header or a body.
|
|
12
|
+
// `insecure`. An error names the method and the redacted URL, never a header or a body. Before a byte goes to a plain
|
|
13
|
+
// http loopback URL the server end of the connection is told from the socket table (DEC-0154 HA-2, FAA-226): not this
|
|
14
|
+
// OS account's process, or an owner that cannot be told, is an HttpError of kind foreign-user, nothing sent, not retried.
|
|
13
15
|
//
|
|
14
16
|
// The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
|
|
15
17
|
// { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
|
|
16
18
|
// HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
|
|
17
19
|
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
22
|
+
|
|
18
23
|
export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
|
|
19
24
|
export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
|
|
20
25
|
export const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -30,7 +35,7 @@ export class HttpError extends Error {
|
|
|
30
35
|
this.attempts = attempts;
|
|
31
36
|
this.retryAfterS = retryAfterS;
|
|
32
37
|
this.body = body.slice(0, 2048);
|
|
33
|
-
this.kind = kind; // http, connection, timeout, too-large, refused
|
|
38
|
+
this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -147,8 +152,53 @@ function checkHeaders(headers) {
|
|
|
147
152
|
return out;
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
|
|
155
|
+
const LOOPBACK_AGENT = new CheckedAgent();
|
|
156
|
+
|
|
157
|
+
// Plain http to a loopback host (DEC-0154 HA-2): node:http over a connection checked before a byte is written, no
|
|
158
|
+
// redirect followed, the body read to one byte past maxBytes. fetch cannot hand its socket to the check.
|
|
159
|
+
export function checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes, agent = LOOPBACK_AGENT }) {
|
|
160
|
+
const parts = new URL(url);
|
|
161
|
+
// A body goes with its Content-Length, as fetch sent it: a server that reads only Content-Length (the kit's
|
|
162
|
+
// sample_service, the stdlib handlers) would read a chunked body as empty (0.74.0 seam reading 1).
|
|
163
|
+
const sent = { ...headers };
|
|
164
|
+
if (body && !Object.keys(sent).some((k) => k.toLowerCase() === 'content-length')) sent['Content-Length'] = String(body.length);
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const req = http.request({ host: parts.hostname.replace(/^\[|\]$/g, ''), port: Number(parts.port || 80),
|
|
167
|
+
path: parts.pathname + parts.search, method, headers: sent, agent }, (res) => {
|
|
168
|
+
const chunks = [];
|
|
169
|
+
let size = 0;
|
|
170
|
+
res.on('data', (c) => {
|
|
171
|
+
if (size <= maxBytes) { chunks.push(c); size += c.length; }
|
|
172
|
+
if (size > maxBytes) res.destroy();
|
|
173
|
+
});
|
|
174
|
+
res.on('close', () => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
const all = Buffer.concat(chunks);
|
|
177
|
+
const got = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : String(v)]));
|
|
178
|
+
if (!res.complete && size <= maxBytes) reject(Object.assign(new Error('the answer broke off'), { name: 'ConnectionError' }));
|
|
179
|
+
else resolve({ status: res.statusCode, headers: got, body: new Uint8Array(all.subarray(0, maxBytes + 1)) });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
const timer = setTimeout(() => req.destroy(Object.assign(new Error('timed out'), { name: 'TimeoutError' })),
|
|
183
|
+
Math.max(1, Math.ceil(timeoutS * 1000)));
|
|
184
|
+
req.on('error', (err) => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
if (err instanceof ListenerRefused) {
|
|
187
|
+
reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user' }));
|
|
188
|
+
} else reject(err);
|
|
189
|
+
});
|
|
190
|
+
if (body) req.write(body);
|
|
191
|
+
req.end();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http to a
|
|
196
|
+
// loopback host goes through checkedLoopbackTransport.
|
|
151
197
|
export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
|
|
198
|
+
const target = new URL(url);
|
|
199
|
+
if (target.protocol === 'http:' && isLoopback(target.hostname)) {
|
|
200
|
+
return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes });
|
|
201
|
+
}
|
|
152
202
|
const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
|
|
153
203
|
signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
|
|
154
204
|
const chunks = [];
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// $XDG_DATA_HOME/passioncode-fabric) the way Fabric's own locateHub does: a small regular file, never a link or a
|
|
7
7
|
// FIFO; protocol fabric-hub/0.1; origin exactly http://127.0.0.1:<port>; MCP at /mcp; a door token file inside the
|
|
8
8
|
// registry root; a pid that is alive. The port does not prove who holds it, so a binding credential is sent only
|
|
9
|
-
// after this check passes, done again before each send.
|
|
9
|
+
// after this check passes, done again before each send. The pid is never the proof (DEC-0154 HA-2): every
|
|
10
|
+
// connection to the door is checked from the socket table before a byte goes out (fabric-peer-owner.mjs), and a
|
|
11
|
+
// listener of another OS account, or one whose owner cannot be told, is HubError foreign-user with nothing sent.
|
|
10
12
|
// 2. Ask once, keep the credential in a file: requestBoardAccess() asks for the board.respond grant through the door;
|
|
11
13
|
// pollAccess() writes the credential, shown once, to a 0600 file and answers without it. Tokens live in files and
|
|
12
14
|
// headers only — never argv, a URL, a log line or an error text.
|
|
@@ -25,6 +27,12 @@ import os from 'node:os';
|
|
|
25
27
|
import path from 'node:path';
|
|
26
28
|
import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
|
|
27
29
|
import { hubKeyCertified } from './fabric-estate.mjs';
|
|
30
|
+
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
31
|
+
|
|
32
|
+
// DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
|
|
33
|
+
// socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
|
|
34
|
+
// keep-alive timeout (5 s in Node) can close it under a request that reuses it.
|
|
35
|
+
const HUB_AGENT = new CheckedAgent({ keepAlive: true, timeout: 2000 });
|
|
28
36
|
|
|
29
37
|
export const HUB_PROTOCOL = 'fabric-hub/0.1';
|
|
30
38
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
@@ -158,9 +166,10 @@ export function readCredential(file) {
|
|
|
158
166
|
/**
|
|
159
167
|
* One MCP request to <origin>/mcp with a bearer in the header; only http://127.0.0.1:<port>, no proxy. Answers
|
|
160
168
|
* {status, headers, body} (body parsed JSON or null; an SSE answer read to its first data: line). A refused connection
|
|
161
|
-
* is HubError hub-unavailable (retryable)
|
|
169
|
+
* is HubError hub-unavailable (retryable); a listener that is not this OS account's process is HubError foreign-user
|
|
170
|
+
* (retryable) before the bearer is sent (DEC-0154 HA-2): the connection is checked from the socket table first.
|
|
162
171
|
*/
|
|
163
|
-
export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion } = {}) {
|
|
172
|
+
export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion, agent = HUB_AGENT } = {}) {
|
|
164
173
|
const m = LOOPBACK_ORIGIN.exec(origin);
|
|
165
174
|
if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
|
|
166
175
|
const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
|
|
@@ -169,7 +178,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
|
|
|
169
178
|
if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
|
|
170
179
|
if (payload) headers['content-length'] = String(payload.length);
|
|
171
180
|
return new Promise((resolve, reject) => {
|
|
172
|
-
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent
|
|
181
|
+
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent }, (res) => {
|
|
173
182
|
const chunks = [];
|
|
174
183
|
let size = 0;
|
|
175
184
|
res.on('data', (c) => {
|
|
@@ -192,7 +201,9 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
|
|
|
192
201
|
res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
|
|
193
202
|
});
|
|
194
203
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
195
|
-
req.on('error', (e) => reject(
|
|
204
|
+
req.on('error', (e) => reject(e instanceof ListenerRefused
|
|
205
|
+
? new HubError('foreign-user', `The hub's port is not held by this OS account's process (${e.owner?.kind ?? e.code}); nothing was sent.`, true)
|
|
206
|
+
: new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
|
|
196
207
|
if (payload) req.write(payload);
|
|
197
208
|
req.end();
|
|
198
209
|
});
|