@passioncode-ai/fabric-agent-adapter 0.73.0 → 0.74.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +4 -3
  3. package/SECURITY.md +17 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +14 -15
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +3 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +33 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +37 -3
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +122 -9
  31. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
  33. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  34. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.74.0 (2026-11-07)
4
+
5
+ The contract pin moves to `18f8d487` (`v0.1.0+2026-11-07.1`) from `eac1a594` (`v0.1.0+2026-11-06.1`); between them
6
+ only DEC-0154 (several OS accounts on one computer). `eac1a594` stays in `SUPPORTED_CONTRACT_COMMITS`, so bundles
7
+ issued under it stay valid; a new scaffold writes `18f8d487`.
8
+
9
+ ### Added
10
+
11
+ - **The listener's owner before a credential** (FAA-226, the contract's DEC-0154 HA-2). Loopback is one per computer,
12
+ not one per OS account, so every kit client that sends a credential to `http://127.0.0.1:<port>` first tells the
13
+ owner of the server end of its own connection from the computer's socket table, and sends nothing to another OS
14
+ account's process, root's, SYSTEM's, or one whose owner cannot be told (`foreign-user`):
15
+ - a new layer-L0 module, `fabric_peer_owner` / `fabric-peer-owner.mjs`. The Python file is Project Observatory's
16
+ `peer_owner.py` 1.1.0, vendored by commit (`f86f0622`, the same licence), with `Owner` and `Row` as NamedTuples so
17
+ the module loads by its path and `Optional[X]` in its signatures for the Python 3.9 floor; it reads macOS through `sysctl net.inet.tcp.pcblist_n` (calibrated on a connected pair
18
+ of its own, else `netstat -anv`), Linux through `/proc/net/tcp` and `/proc/net/tcp6`, Windows through
19
+ `GetExtendedTcpTable` and the owner's SID. The Node twin reads `netstat -anv` and `ps` on macOS, the same `/proc`
20
+ files on Linux, `netstat -ano`, `whoami /user` and a PowerShell `GetOwnerSid` (SIDs, never names) on Windows; `checkedSocket` and `CheckedAgent` check a connection
21
+ before a byte is written. Both twins answer the same questions over `test/fixtures/peer-owner/peer-owner-cases.json`.
22
+ - `check_service.py`: a new rule for a local placement, `listener.owner`, the first that reads the port, from the server end of a connection
23
+ of the probe's own on which nothing is sent: `PASS` for this OS account's process,
24
+ `FAIL` `foreign-user` (then no rule reads the port and no token is sent), `NOT_RUN` when nothing listens. Every
25
+ local request goes on a checked connection.
26
+ - the hub client (`fabric_hub.post_mcp`, `postMcp`): `HubError` code `foreign-user`, retryable, before the bearer; the
27
+ live pid in `hub.json` stays a precondition and is never the proof. The Node client keeps a checked connection
28
+ alive (closed after 2 s idle), so a busy session reads the socket table once per connection.
29
+ - `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL: `HttpError` kind `foreign-user`, never
30
+ retried; the Node twin sends such a request through `node:http` on a checked connection instead of `fetch`.
31
+ - `check_dashboard_layout.mjs` before it asks a local service for a login code (a copy of that file alone, without
32
+ the kit beside it, sends nothing to an origin that is not `https://`).
33
+ - the launchd installers' poll: a listener of another account is never a successful install; the error names
34
+ `foreign-user`.
35
+
36
+ ### Upgrading
37
+
38
+ - `check_service.py` prints one more line for a local service (`listener.owner`), and a local service whose port is
39
+ held by another OS account's or root's process, or by one whose owner cannot be told (a sandbox without `sysctl` or
40
+ `netstat`, a socket launchd holds for a socket-activated job), now fails (exit 1) where 0.73.0 read its answer.
41
+ When the port's rules cannot be read (that FAIL, or a well-known document that gives no answer), one `port.rules`
42
+ `NOT_RUN` line names them, where 0.73.0 left them out of the report.
43
+ A socket-activated job's listening socket belongs to whoever created it: a systemd user unit's to this account, a
44
+ system unit's and launchd's to root (pid 1), so such a service binds its own listener or runs as a user unit.
45
+ - `fetch_well_known` (re-exported by `fabric_service`) answers `None` for a listener of another account, and
46
+ `launchd_install` / `launchdInstall` now raises, naming `foreign-user`, where 0.73.0 returned success when another
47
+ account's copy of the service answered on the port.
48
+ - The Node `fabric-http.mjs` sends a plain `http://` loopback request through `node:http` on a checked connection, not
49
+ `fetch`: a caller or test that stubs the global `fetch` for a loopback URL is no longer reached (pass `transport`).
50
+ - On a computer with more than one counted OS account a loopback caller with no token is not T0 until the door checks
51
+ its peer's account (the pinned HA-3 and DL-25; the kit's own peer check is FAA-227): pass `tier="T0"` to `gate` or
52
+ `server.handle` there only for a caller the descriptor token authenticated (`references/delegation.md`).
53
+ - Services that vendored 0.73.0 kit files keep sending to loopback unchecked until their copies are refreshed
54
+ (`fabric_kit.py status` names them `older`). `fabric-supervisors.mjs` exports `wellKnownOf` and `ForeignUserAnswer`
55
+ (new names), and `fabric_peer_owner` reports its version as `1.1.0+kit.1`.
56
+ - `fabric_http`, `fabric-http.mjs` and the hub client refuse a loopback listener of root or a service account too (a
57
+ rootful container proxy in front of a local model gateway, for one): use a rootless runtime or a forwarder of your own
58
+ account. The Python `fabric_http` no longer sends a loopback URL through a proxy from the environment (`http_proxy`).
59
+ - `fabric_peer_owner` is now imported by `check_service.py`, `fabric_hub`, `fabric_http`, `fabric_supervisors` and the
60
+ Node `fabric-hub.mjs`, `fabric-supervisors.mjs` and `fabric-http.mjs` (which imported no kit module before): a project
61
+ that refreshes vendored copies file by file takes each module's `kit.json` closure, or the import fails.
62
+ - A socket table over 4 MiB cannot be read (about 6,800 TCP sockets for the Python reader on macOS, 617 bytes each in
63
+ `pcblist_n`; about 19,000 for `netstat -anv`, the Node reader there; about 28,000 in Linux's `/proc/net/tcp`), so every loopback send fails closed as `foreign-user` on such a host.
64
+ - On Linux the server end of an accepted connection is told by its own row (the uid of the process that accepted it),
65
+ and of one not accept()ed yet, which `/proc` prints with uid 0 and inode 0, by the listener row on its local address
66
+ (state 0A; the exact address, else the wildcard), as Observatory's `peer_owner.py` 1.2.0 reads it; the contract's
67
+ CT-254 is settling HA-2's Linux row (FAA-258). A client row with inode 0 (a closed client) reads `unknown`. Several
68
+ listening rows of one owner (SO_REUSEPORT workers, an IPv4 and an IPv6 socket of one process) read as that owner;
69
+ rows of two owners stay `unknown`.
70
+ - A `/proc` read that sockets came and went under can print a row twice or skip one (Linux resumes it by position):
71
+ rows equal in every field count once, and a `/proc` question that finds no row or several reads the table once
72
+ more before it answers `unknown`. Without it a busy Linux host refused its own service now and then.
73
+ - The Python module imports `ctypes` only where it reads with it, so a CPython built without libffi still imports
74
+ every kit module; the owner check then reads `netstat` on macOS and fails closed on Windows.
75
+ - A client running as root or SYSTEM reads root's or SYSTEM's listener as its own account's (launchd's pid 1 stays
76
+ `system`): the vendored module put the system rule first, which would have refused a root container's own sidecar.
77
+ - The hub client and `fabric_http` refuse a loopback listener whose owner cannot be told (a socket table the process
78
+ cannot read): nothing is sent. Fail closed, as HA-2 asks.
79
+ - A copy of `check_dashboard_layout.mjs` alone refuses a `--service` whose origin is not `https://`; run it from the
80
+ kit's folder. Which way a request goes follows its origin, not the descriptor's `placement`: a descriptor that says
81
+ `remote` with an `http://` origin is asked through the checked transport too.
82
+ - A service's own `<tool> mcp-register` now establishes that the listener is this OS account's before it writes the
83
+ token (`references/surfaces-and-auth.md`: `fabric_peer_owner.server_end` on a connection it opens and sends nothing
84
+ on, as `listener.owner` does; `foreign-user` writes nothing). The kit ships no `mcp-register` helper: one written for
85
+ 0.73.0 or earlier writes the token unchecked until its author adds the check.
86
+ - Every new checked loopback connection from the Node twin starts processes: on macOS `netstat -anv` and `ps` (about
87
+ 20 to 35 ms), on Windows `netstat -ano` twice and one PowerShell `GetOwnerSid` (5 s cap each, not yet measured,
88
+ FAA-256). The hub client keeps its checked connection alive; `fabric-http.mjs` does not, so each loopback request
89
+ pays it.
90
+ - The Python `fetch_well_known` answers `None` both when nothing listens and for a `foreign-user` listener; a status
91
+ command that must report `foreign-user` (the order `down`, `foreign-user`, `foreign`, then `status`) asks
92
+ `fabric_peer_owner.server_end` itself, or uses the Node `wellKnownOf`, which answers `ForeignUserAnswer` (FAA-259).
93
+ - The Python `launchd_install` polls through the module's `_read_well_known`, no longer through `fetch_well_known`:
94
+ a test that faked the poll by patching `fabric_supervisors.fetch_well_known` now makes real connections and times out
95
+ (the Node `launchdInstall` still takes an injected `fetchWellKnown`; FAA-259 gives Python the same).
96
+
97
+ ### Changed
98
+
99
+ - `references/protocol.md` gains "The listener's owner (DEC-0154 HA-2)"; `references/board.md`, `references/lifecycle.md`,
100
+ `references/kit-modules.md` and `SKILL.md` name the check. `references/surfaces-and-auth.md` gives `mcp-register` the
101
+ check, `references/interop.md`'s real-client gate waits for `listener.owner` PASS, and the README's MCP quick start
102
+ writes the client config only after the probe passes.
103
+ - The test fake hub keeps HTTP/1.1 connections with no Nagle delay, as a Node server does by default (whether Fabric's
104
+ own hub keeps idle connections past the Node client's 2 s is FAA-256).
105
+ - `dashboard-links.md` (all three copies) opens a raw loopback address in a browser only after the listener is this OS
106
+ account's (the pinned PL-3); the link gate enforcing it is FAA-251. `connectors.md` names the loopback owner check
107
+ and the kind `foreign-user`; the skill card's Network row names the socket table the kit now reads; `SKILL.md` and
108
+ `references/lifecycle.md` read a service's identity only from this account's listener, with a scenario eval for a
109
+ port another account holds; `references/delegation.md` says when a token-free loopback caller is T0 (HA-3);
110
+ `SECURITY.md` names the socket-table reads and the programs the kit now starts. Owed, each its own row: FAA-251 (the
111
+ link gate), FAA-252 (a running board session's reason), FAA-253 (the systemd and Task Scheduler installers' poll),
112
+ FAA-254 (working-with-fabric's "Fabric is up"), FAA-255 (the delegation key set read over loopback), FAA-256 (the Node
113
+ check's cost on Windows), FAA-257 (the OpenRouter connector reports a refused loopback gateway as no answer), FAA-258 (re-pinning
114
+ to CT-254 once it is tagged and reconciling with Observatory's `peer_owner.py` 1.2.0), FAA-259 (the Python
115
+ `fetch_well_known` telling `foreign-user` from `down`), FAA-260 (the readings' smaller follow-ups).
116
+
3
117
  ## 0.73.0 (2026-11-07)
4
118
 
5
119
  ### Added
package/README.md CHANGED
@@ -188,8 +188,9 @@ KIT=plugins/fabric-agent-adapter/skills/building-fabric-services/scripts # or
188
188
  DATA=$(mktemp -d); SERVICES=$(mktemp -d); WORK=$(mktemp -d)
189
189
  python3 "$KIT/sample_service.py" serve --port 47190 --data-dir "$DATA" &
190
190
  python3 "$KIT/sample_service.py" register --port 47190 --data-dir "$DATA" --services-dir "$SERVICES"
191
- python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" # exit 0: no FAIL
192
- # Register the service for one client run; the token goes in a header, never in argv.
191
+ # The probe exits 0 (no FAIL); only then is the service registered for one client run (listener.owner proves the port
192
+ # is this account's, and the client cannot check it). The token goes in a header, never in argv.
193
+ python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" && \
193
194
  python3 - "$DATA/service.token" "$WORK/mcp.json" <<'EOF'
194
195
  import json, os, sys
195
196
  token = open(sys.argv[1]).read().strip()
@@ -301,7 +302,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
301
302
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
302
303
 
303
304
  - version: `0.1.0`
304
- - commit: `eac1a594aa131851f079da8bb7b2149e1951f35f`
305
+ - commit: `18f8d48762edc5bfb1433657cbfe1c16196df1a6`
305
306
 
306
307
  Every other mention of the default contract revision (this section, the skills' metadata, the
307
308
  skill card, `adapt_project.py`) must equal it; `python3 test/validate.py` fails a tree
package/SECURITY.md CHANGED
@@ -32,6 +32,23 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
32
32
  external APIs the service's author configures, such as OpenRouter, with keys the service
33
33
  reads by name at run time; the kit itself calls no outside host on its own. A board
34
34
  responder talks only to Fabric's hub on loopback.
35
+ - Before `check_service.py`, the hub client, `fabric_http` (a plain `http://` loopback URL),
36
+ `check_dashboard_layout.mjs` (a local login code) and the launchd installers' poll send
37
+ anything to `127.0.0.1:<port>`, they read the computer's socket table to tell which OS
38
+ account owns the listener (DEC-0154 HA-2, `fabric_peer_owner`), and send nothing to
39
+ another account's, root's, SYSTEM's or an untellable listener. They read, never change:
40
+ macOS `sysctl net.inet.tcp.pcblist_n` in the process, or the programs `/usr/sbin/netstat
41
+ -anv -p tcp` and `/bin/ps -o uid= -p <pid>` (the Node twin always); Linux
42
+ `/proc/net/tcp` and `/proc/net/tcp6`; Windows `GetExtendedTcpTable` and the process
43
+ token (Python), or `netstat -ano`, `whoami /user` and one PowerShell `GetOwnerSid` query
44
+ per new connection (Node). A token-free loopback door is not yet protected against
45
+ another account's process (HA-3, FAA-227). An MCP client (Claude Code, Codex) sends the
46
+ token it was registered with on every later connection without this check (the contract's
47
+ residue "a coding agent's bearer"): when the port may have changed hands, rotate the token
48
+ and re-run `mcp-register` (one written as `references/surfaces-and-auth.md` says checks the
49
+ listener first; the kit ships none). On Linux `/proc` names a socket's creator and
50
+ no pid, so a listening socket root created and handed to another account (systemd
51
+ socket activation with `User=`) reads as root's: a client running as root sends to it.
35
52
  - The working-with-fabric PostToolUse hook (FAA-206) reads only the release call Claude Code gives it on stdin (the
36
53
  tool's name, its input, which can carry the agent's note, and Fabric's answer), in memory: it writes, logs and sends
37
54
  nothing and prints one fixed line or nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.73.0",
6
+ "version": "0.74.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.73.0"
8
+ version: "0.74.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "eac1a594aa131851f079da8bb7b2149e1951f35f"
10
+ contract-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -76,7 +76,7 @@ Use exactly:
76
76
 
77
77
  - contract version `0.1.0`;
78
78
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
79
- - commit `eac1a594aa131851f079da8bb7b2149e1951f35f`.
79
+ - commit `18f8d48762edc5bfb1433657cbfe1c16196df1a6`.
80
80
 
81
81
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
82
82
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -62,8 +62,12 @@ the fabric-dashboards MCP (`list_services`, never guessed) and hand over the
62
62
  `open_link` its `link` tool returns for the page's path; to open it, call the
63
63
  host's `open` with `fallback=never`. Never `open <url>`, `open -a Safari`,
64
64
  `xdg-open`, `start <url>`, `webbrowser.open` or `shell.openExternal` on this
65
- agent's pages. A browser only when `host_status` confirms `not_installed`; an
66
- installed host that failed to open is reported, not replaced by HTTP. Links
65
+ agent's pages. A browser only when `host_status` confirms `not_installed`, and
66
+ a raw `http://127.0.0.1:<port>` address only after the listener on that port is
67
+ this OS account's (DEC-0154 PL-3 and HA-2; the adapter's `building-fabric-services/scripts/fabric_peer_owner.py`: `server_end` of a connection it opens and sends nothing on; `check_service.py <id>.<instance>` prints it as `listener.owner`): a
68
+ `foreign-user` listener is reported, never opened (the link gate below does not
69
+ read the owner yet, FAA-251). An installed host that failed
70
+ to open is reported, not replaced by HTTP. Links
67
71
  carry no token or login code. Every completion and notification action passes
68
72
  the Fabric Agent Adapter's link gate before delivery: `check_dashboard_link.py`
69
73
  in its `building-fabric-services/scripts`, run from that skill's directory;
@@ -22,10 +22,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
22
22
 
23
23
  CONTRACT_VERSION = "0.1.0"
24
24
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
25
- CONTRACT_COMMIT = "eac1a594aa131851f079da8bb7b2149e1951f35f"
25
+ CONTRACT_COMMIT = "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
26
26
  # #region supported-contract-revisions — docs: README.md#contract-pin
27
27
  # Only this declaration may name a legacy revision in live files.
28
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "3830a3353923e20f826d466610e1c479f120bec0", "00e050ba7429c0c60261cec871779e0fd6aeeb28", "1197a90dc4b57152cabcc71ad17db7559f35a27f", "e6c4fc16b415eec3190dd34d924d269405359dc9", "a3a66eb0052043847f6556348e37d34ca873a629", "52c7d798b9f4eb784eb4e29e717038a8912c759d", "2f020a696453ae299b123517f14957f6a1354379", "87d384ffce2757081b7ab227e5dad3dddd08328f", "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "eac1a594aa131851f079da8bb7b2149e1951f35f", "3830a3353923e20f826d466610e1c479f120bec0", "00e050ba7429c0c60261cec871779e0fd6aeeb28", "1197a90dc4b57152cabcc71ad17db7559f35a27f", "e6c4fc16b415eec3190dd34d924d269405359dc9", "a3a66eb0052043847f6556348e37d34ca873a629", "52c7d798b9f4eb784eb4e29e717038a8912c759d", "2f020a696453ae299b123517f14957f6a1354379", "87d384ffce2757081b7ab227e5dad3dddd08328f", "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
29
29
  # #endregion supported-contract-revisions
30
30
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
31
31
  MCP_REVISION = "2026-07-28"
@@ -14,10 +14,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.73.0"
17
+ version: "0.74.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
- extension-commit: "eac1a594aa131851f079da8bb7b2149e1951f35f"
20
+ extension-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
21
21
  ---
22
22
 
23
23
  # Building Fabric services
@@ -71,7 +71,7 @@ Answer before writing code, and record the answers in the project README:
71
71
 
72
72
  | Caller | Surface | Why |
73
73
  |---|---|---|
74
- | Another agent on this machine | MCP `2026-07-28`, `streamable-http`, at `/mcp` on the service origin, token in a header | one process serves every session (stdio-per-session once left twenty stray servers) |
74
+ | Another agent on this machine | MCP `2026-07-28`, `streamable-http`, at `/mcp` on the service origin, token in a header | one process serves every session; `mcp-register` checks the listener (HA-2) first |
75
75
  | The operator or a script | a CLI: `<tool> service status\|start\|stop\|restart`, `<tool> dashboard` (opens only in Fabric Dashboards, below), `<tool> doctor --json` | every verb delegates to launchd and the well-known document, never to its own process table |
76
76
  | A remote agent | A2A `1.0` over HTTPS, or MCP behind an authenticated gateway | loopback reachability is not authorization |
77
77
  | An A2A peer on this computer | `surfaces.a2a` names a public Agent Card, served without the token; its interface URLs are loopback (DEC-0122); the kits read it, serving one is FAA-10 | the card carries no credential, process member or required Fabric extension |
@@ -220,7 +220,7 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
220
220
  (`launchd_uninstall`, `remove_descriptor`) as soon as its check is done, not left running.
221
221
  2. `launchd_plist(...)` then `launchd_install(...)`: writes and lints the plist,
222
222
  `bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
223
- error, then polls the well-known document until it answers with **this** identity.
223
+ error, then polls the well-known document until **this** identity answers on this OS account's listener (HA-2).
224
224
  A label the operator disabled stays disabled: only a first install enables.
225
225
  3. Uninstall: `launchd_uninstall` (waits for the unload, removes the plist, resets the
226
226
  override; `purge=True` removes data only when the operator asks), `remove_descriptor`.
@@ -292,14 +292,14 @@ directory, restores only on a fresh database and never overwrites. Read
292
292
 
293
293
  ## Step 6: verify
294
294
 
295
- Run the probe against the live service: it reads the descriptor, then checks every
296
- rule it can reach and says which it could not:
295
+ Run the probe against the live service; it checks every rule it can reach and names
296
+ those it could not:
297
297
 
298
298
  ```bash
299
299
  python3 <skill-dir>/scripts/check_service.py <id>[.<instance>]
300
300
  ```
301
301
 
302
- It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, `descriptor.source-repository`,
302
+ It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, `descriptor.source-repository`, `listener.owner` (HA-2), `port.rules`,
303
303
  `well-known.connections`, well-known
304
304
  shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token file,
305
305
  events auth and shape, single-use login, state outside code, instance lock, plist,
@@ -409,28 +409,27 @@ this estate needs.
409
409
  - **A desktop app with no supervisor** (DEC-0069): its descriptor says `manager: "person"` with `lifecycle.app`, no
410
410
  origin, `installedBy {by: "person", role, tool}`, `source.repository` (the lifecycle reference, *An app's own
411
411
  descriptor and the node*).
412
- - **No Python:** use the Node kit; run the probe from any machine that has Python and
413
- can reach the port, or mark the probe `NOT_RUN` with the reason.
412
+ - **No Python:** use the Node kit; run the probe where Python reaches the port, or mark it `NOT_RUN`
413
+ with the reason.
414
414
  - **Neither kit fits the framework:** implement the four routes and the lock by hand
415
415
  against [the protocol reference](references/protocol.md); the probe is the judge.
416
416
  - **No contract checkout:** the protocol reference is pinned to the extension commit
417
417
  in this skill's metadata; do not reconstruct fields from memory.
418
- - **Fabric Dashboards not installed:** nothing changes; the service is complete
419
- without a host; the descriptor waits for one.
418
+ - **Fabric Dashboards not installed:** nothing changes; the descriptor waits for a host.
420
419
 
421
420
  ## Gotchas
422
421
 
423
422
  - `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong even if the Host check works.
424
- - A health probe that only checks HTTP 200 accepts any program on the port: compare
425
- `service.id` and `service.instance`.
423
+ - HTTP 200 alone accepts any program on the port: compare `service.id` and `service.instance`, read only from
424
+ this OS account's listener. On `listener.owner` FAIL (`foreign-user`) send that port nothing.
426
425
  - `launchctl bootstrap` right after `bootout` fails with I/O error 5: wait for the unload, then retry.
427
426
  - Under launchd `PATH` is bare. Pin the interpreter by absolute path; set `PATH`
428
427
  explicitly in the plist; never copy the whole shell environment into it.
429
428
  - A plist pointing at a worktree or checkout breaks when it moves: point it at a release.
430
429
  - `http.server.HTTPServer` resolves its FQDN between `bind()` and `listen()`: with a slow
431
430
  resolver (a macOS CI runner, a Mac offline) the port is bound but silent. Use `fs.LoopbackHTTPServer`.
432
- - `logging.basicConfig` called twice: the second is a no-op, the server's log file
433
- stays empty. Configure logging once, in the entry point.
431
+ - `logging.basicConfig` called twice: the second is a no-op and the log stays empty. Configure logging once, in
432
+ the entry point.
434
433
  - Hand-made `.plist.bak` files in `~/Library/LaunchAgents` are never cleaned up; keep
435
434
  backups elsewhere.
436
435
 
@@ -35,7 +35,10 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
35
35
  binding credential, shown once, goes into `credential_file` (0600, atomic) and never into the answer, a log or argv.
36
36
  Keep the file in the service's data directory or the operator's vault.
37
37
  3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
38
- checks the pid it names is alive, and only then sends the credential. A missing grant is `HubError` code
38
+ checks the pid it names is alive, tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
39
+ another OS account's process, or one whose owner cannot be told, is `HubError` code `foreign-user`, retryable, with
40
+ nothing sent), and only then sends the credential (a session already running maps that refusal, as any transport
41
+ failure, to `board.down` with reason `unreachable` until FAA-252). A missing grant is `HubError` code
39
42
  `not_authorized` whose `data.ask` holds the arguments to ask with.
40
43
 
41
44
  ## Wiring it into the service
@@ -37,6 +37,7 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
37
37
  | The body is read to one byte past `max_bytes`; over it is an error | 8 MiB | a runaway answer never fills memory |
38
38
  | Redirects are answered, never followed | | a redirect must not carry the `Authorization` header to another host |
39
39
  | Plain `http://` is refused unless the host is loopback or `insecure=True`; credentials in a URL are refused | | a token never crosses a network in clear, nor sits in a URL |
40
+ | Plain `http://` to a loopback host goes on a connection whose server end is this OS account's process, told from the socket table before a byte is sent and never through a proxy from the environment (`fabric_peer_owner`); another account's listener, root's, or one whose owner cannot be told is kind `foreign-user`, nothing sent, never retried. The Node twin sends such a request through `node:http` on a checked connection, not `fetch` | | DEC-0154 HA-2: loopback is one per computer, not one per OS account |
40
41
  | Header names must be tokens; a value with a line break or NUL is refused | | header injection |
41
42
  | A JSON body is written as `JSON.stringify` writes it: a lone surrogate escaped, NaN and Infinity refused | | both twins send the same bytes |
42
43
  | Whatever the transport throws is a connection error, in both languages; a timeout is a timeout | | |
@@ -44,7 +45,7 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
44
45
  | `Response.json()` reads as `JSON.parse` does: a byte order mark skipped, `NaN` and `Infinity` refused | | |
45
46
 
46
47
  Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
47
- `connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
48
+ `connection`, `timeout`, `too-large`, `refused` or `foreign-user`, the status when there was one, and the URL redacted to scheme,
48
49
  host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`, and `ssh:<redacted>`
49
50
  for `ssh://h:99999/r` too), and an http, https, ws, wss or ftp URL that does not parse (Node reads it with `new URL`;
50
51
  Python reads its own special-scheme form and prints what it finds, never the user information), or a value with no
@@ -62,8 +62,12 @@ the fabric-dashboards MCP (`list_services`, never guessed) and hand over the
62
62
  `open_link` its `link` tool returns for the page's path; to open it, call the
63
63
  host's `open` with `fallback=never`. Never `open <url>`, `open -a Safari`,
64
64
  `xdg-open`, `start <url>`, `webbrowser.open` or `shell.openExternal` on this
65
- agent's pages. A browser only when `host_status` confirms `not_installed`; an
66
- installed host that failed to open is reported, not replaced by HTTP. Links
65
+ agent's pages. A browser only when `host_status` confirms `not_installed`, and
66
+ a raw `http://127.0.0.1:<port>` address only after the listener on that port is
67
+ this OS account's (DEC-0154 PL-3 and HA-2; the adapter's `building-fabric-services/scripts/fabric_peer_owner.py`: `server_end` of a connection it opens and sends nothing on; `check_service.py <id>.<instance>` prints it as `listener.owner`): a
68
+ `foreign-user` listener is reported, never opened (the link gate below does not
69
+ read the owner yet, FAA-251). An installed host that failed
70
+ to open is reported, not replaced by HTTP. Links
67
71
  carry no token or login code. Every completion and notification action passes
68
72
  the Fabric Agent Adapter's link gate before delivery: `check_dashboard_link.py`
69
73
  in its `building-fabric-services/scripts`, run from that skill's directory;
@@ -52,7 +52,9 @@ service's own local floor (the descriptor token, the Host guard) has authenticat
52
52
  the same owner; **T2** everyone else, and any call relayed onto loopback from off the machine (a tunnel, a proxy, the
53
53
  hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token; pass the tier,
54
54
  `gate(tool, headers, tier="T0")` or `server.handle(message, headers, tier="T0")`, and the call passes with no
55
- delegation. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
55
+ delegation. On a computer with more than one counted OS account, a loopback caller with no token is T0 only after the
56
+ door checks the peer's account (the pinned DEC-0154 HA-3 and DL-25; the kit's own peer check is FAA-227), so pass
57
+ `tier="T0"` there only for a caller the descriptor token authenticated. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
56
58
  profile. Money limits and human gates bind at every tier.
57
59
 
58
60
  **The family and the agent network** (DEC-0130, wording settled by DEC-0137). The operator's family is every agent
@@ -77,7 +77,9 @@ untraced work carries no trace pair.
77
77
  **Check with a real client**, never only an SDK: `FABRIC_REAL_CLIENT=1 python3 -m unittest
78
78
  test.test_real_client` in the adapter repository, or by hand: a throwaway directory, a
79
79
  temporary `--mcp-config` (mode 600, the token in a header) and `--strict-mcp-config`, so
80
- the operator's own configuration is not touched:
80
+ the operator's own configuration is not touched. Write that config only after `check_service.py <id>` reads
81
+ `listener.owner` PASS: the client cannot tell the port's owner, so the side that hands it the token does (DEC-0154
82
+ HA-2):
81
83
 
82
84
  ```bash
83
85
  claude -p "Reply with the single word OK." --strict-mcp-config --mcp-config "$TMP/mcp.json" \
@@ -59,6 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.1`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable` |
62
63
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
63
64
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
64
65
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
@@ -48,10 +48,12 @@ refuses a port another instance declares), the service's directories, and comman
48
48
  carry a credential. Only `doctor` and `update` are commands a host may run.
49
49
 
50
50
  **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
51
- authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's.
51
+ authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's,
52
+ on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers).
52
53
  `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
53
54
  full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
54
- that document until `service.id` matches (at most 40 s) before it calls the service started.
55
+ that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
56
+ systemd and Task Scheduler do not poll yet, FAA-253).
55
57
 
56
58
  | Id | Rule | Here | Kit (Python / Node) |
57
59
  |---|---|---|---|
@@ -87,11 +89,15 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
87
89
  1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
88
90
  (a venv or `node_modules` inside it). Never edit a release after it is written.
89
91
  2. `write_descriptor` (port and id claims are checked here, and a new descriptor without `source.repository` is
90
- refused; a rewrite of an installed one warns, FAA-198).
92
+ refused; a rewrite of an installed one warns, FAA-198). "Port claim" here is the kit's older sense, a port no other
93
+ descriptor declares; the pinned contract's HA-1 claim file per OS account, and a port free on the whole computer,
94
+ are not written or checked yet (FAA-225, after the contract's CT-228).
91
95
  3. `launchd_install` (Node: `await launchdInstall(...)`, the same steps): write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
92
96
  `enable` **only when no override is recorded** (a first install), `bootout` and wait
93
97
  until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
94
- then poll the well-known document until it answers with this `id.instance`.
98
+ then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
99
+ (DEC-0154 HA-2: another account's listener, or one whose owner cannot be told, is `foreign-user` and never a
100
+ successful install; the error names it).
95
101
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
96
102
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
97
103
  and the one before it stay for rollback, older ones go (LC-11, LC-15).
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `eac1a594aa131851f079da8bb7b2149e1951f35f`
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 per machine |
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 machine-wide claim. 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
+ | `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 on the same machine reads its daily operations digests, under
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 |
@@ -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 writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
31
- atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
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
 
@@ -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
- const wkRes = await fetch(`${origin}/.well-known/fabric-service`, { headers: remote && token ? { [header]: value } : {} });
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 fetch(`${origin}/fabric/v1/login-code`, { method: 'POST', headers: { [header]: value } });
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
  }