@passioncode-ai/fabric-agent-adapter 0.75.0 → 0.76.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 (22) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +4 -2
  3. package/SECURITY.md +5 -1
  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 +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +10 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -3
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +42 -4
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +31 -7
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +103 -10
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -8
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +100 -8
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.76.0 (2026-11-07)
4
+
5
+ The contract pin stays `18f8d487`. Kit modules change: `fabric_peer_owner` and `fabric-peer-owner.mjs` (peer_owner
6
+ `1.1.0+kit.2`), `fabric_supervisors` and `fabric-supervisors.mjs`, `fabric_http`, and the facades `fabric_service` and
7
+ `fabric-service.mjs` (one more export each).
8
+
9
+ ### Fixed
10
+
11
+ - **The launchd installers prove their own job on a restricted socket table** (FAA-267). macOS 27.2 shows an
12
+ ad-hoc-signed interpreter (a Homebrew Python) only its own sockets through sysctl and nothing through netstat, so
13
+ `launchd_install` refused a service it had just started (`foreign-user`, `no-row`). The sysctl calibration now also
14
+ requires a child process's listener; an empty netstat answers `unknown` with reason `table-restricted`, never another
15
+ account's. `CheckedHTTPConnection(vouch_pid=...)` and the Node `checkedSocket({vouchPid})` accept a listener on the
16
+ port that `lsof -p` shows belongs to that pid of this account; `launchd_install` and `launchdInstall` vouch with their
17
+ own job's pid (`launchd_pid`, `launchdPid`, read only after a `table-restricted` answer), and a refusal that remains reads `<origin> could not be proved this service's:
18
+ table-restricted: ...`: the Python installer names the way round it (Apple's `/usr/bin/python3`), the Node one asks for a runtime that sees the table
19
+ (no Node was measured). `lsof` is asked for IPv4 alone, so a v6-only wildcard never vouches for a `127.0.0.1`
20
+ connection, and a vouch is an integer pid. The calibration child (one short-lived interpreter per process) is bounded
21
+ by the table timeout, and a child that cannot be started or does not answer proves nothing. Measured on a host
22
+ agent's Mac (macOS 27.2) with a pre-release build: the install passes, a root listener on another port is still
23
+ refused, the method is netstat under Homebrew Python and sysctl under Apple's; the final form was not rerun there
24
+ ([record](docs/reports/2026-11-07-faa-267/README.md)).
25
+ - **A release folder in a plist's or a supervisor file's environment is no credential** (FAA-266): `PYTHONPATH`
26
+ holding a release folder under Application Support was refused as a key. An environment value that reads as a path
27
+ (it starts with `/`, `~`, `.` or a drive, has at least two separators and one all-lower-case part such as
28
+ `example-agent`) is now read by the path-aware detector (`secret_argument`, `secretArgument`); any other value keeps
29
+ the strict `secret_value`, so a base64 key whose `/` splits it is still refused: of 60,000 random keys 20 pass,
30
+ against 13 for the strict detector alone ([record](docs/reports/2026-11-07-faa-267/README.md)). `adapt_project.py`'s LC-21 check reads a plugin MCP
31
+ server's `env` the same way. A path whose part after a space has no all-lower-case folder and holds a digit
32
+ run (`.../Application Support/ExampleAgent/Releases/2026-11-07`) is still refused, as in 0.75.0 (FAA-273).
33
+
34
+ ### Known limits
35
+
36
+ - Only the launchd installers vouch. On a restricted table (macOS 27.2 under an ad-hoc-signed interpreter) every
37
+ other caller of the owner check still refuses a loopback listener whose owner cannot be told: the probe
38
+ (`check_service.py`, rule `listener.owner`), the hub client and so the board responder, `fabric_http` and so the
39
+ OpenRouter connector, `mcp-register`, the `dashboard` command, and the layout probe. The
40
+ probe and `fabric_http` say `foreign-user` and carry the `table-restricted` reason; the hub client says only
41
+ `unknown` (FAA-272). The way round for Python is Apple's `/usr/bin/python3`; a Node service has no measured way
42
+ round yet.
43
+ - The vouch is the launchd job's own pid: a `ProgramArguments` wrapper that starts the server as a child (`sh -c`
44
+ without `exec`, `uv run`, `npm start`) cannot vouch for it. Start the server with `exec`, or use Apple's Python.
45
+
46
+ ### Upgrading
47
+
48
+ - `wellKnownOf`'s `ForeignUserAnswer.reason` may start with `table-restricted` beside `foreign-user`. A
49
+ `launchd_install` or `launchdInstall` refusal on a restricted table now reads `<origin> could not be proved this
50
+ service's: table-restricted: ...` where it read `<origin> is not this service's: foreign-user: ...`: match the
51
+ reason as a substring, not the start of the message.
52
+ - New optional arguments: `vouch_pid` on `CheckedHTTPConnection` and `own_connection`, `vouchPid` on `checkedSocket`, `CheckedAgent` and
53
+ `wellKnownOf`. The vouch is consulted only on macOS when the owner reads `table-restricted`; it never overrides
54
+ another account's listener or another unknown reason. New names in both peer-owner twins: `TABLE_RESTRICTED` (the
55
+ reason to match on), `LSOF`, `pid_listens` / `pidListens` and `parse_lsof_listeners` / `parseLsofListeners`; the facades export `launchd_pid` (`fabric_service`) and
56
+ `launchdPid` (`fabric-service.mjs`).
57
+ - A service that vendored the 0.74.0 or 0.75.0 kit keeps refusing its own install on macOS 27.2 until its copies are
58
+ refreshed (`fabric_kit.py status` names them older).
59
+
3
60
  ## 0.75.0 (2026-11-07)
4
61
 
5
62
  Guidance only: no kit module, probe rule or hook changes; the contract pin stays `18f8d487`.
package/README.md CHANGED
@@ -192,6 +192,8 @@ python3 "$KIT/sample_service.py" serve --port 47190 --data-dir "$DATA" &
192
192
  python3 "$KIT/sample_service.py" register --port 47190 --data-dir "$DATA" --services-dir "$SERVICES"
193
193
  # The probe exits 0 (no FAIL); only then is the service registered for one client run (listener.owner proves the port
194
194
  # is this account's, and the client cannot check it). The token goes in a header, never in argv.
195
+ # On macOS 27.2 run these with Apple's /usr/bin/python3: an ad-hoc-signed Python, such as Homebrew's, sees no other
196
+ # process's sockets (FAA-272).
195
197
  python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" && \
196
198
  python3 - "$DATA/service.token" "$WORK/mcp.json" <<'EOF'
197
199
  import json, os, sys
@@ -206,10 +208,10 @@ EOF
206
208
  kill %1
207
209
  ```
208
210
 
209
- Verified 2026-11-07 from the published npm package 0.74.0 (`npm pack` into a throwaway folder, `KIT`
211
+ Verified 2026-11-07 from the published npm package 0.75.0 (`npm pack` into a throwaway folder, `KIT`
210
212
  pointing at its skill) with Claude Code 2.1.299: the probe reported 44 rules, 0 FAIL, `listener.owner` PASS;
211
213
  the CLI connected to the sample over streamable HTTP, called `sample.echo` and printed `ping`
212
- ([record](docs/evidence/runs/2026-11-07-0.74.0-quick-start.md)). The probe prints one line per rule
214
+ ([record](docs/evidence/runs/2026-11-07-0.75.0-quick-start.md)). The probe prints one line per rule
213
215
  (`PASS`, `FAIL` or `NOT_RUN` with its evidence) and exits 1 on any `FAIL`; run it against your
214
216
  own service by its id once its installer has written the descriptor.
215
217
  `FABRIC_REAL_CLIENT=1 python3 -m unittest discover -s test -p test_real_client.py` repeats the connection check
package/SECURITY.md CHANGED
@@ -41,7 +41,11 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
41
41
  -anv -p tcp` and `/bin/ps -o uid= -p <pid>` (the Node twin always); Linux
42
42
  `/proc/net/tcp` and `/proc/net/tcp6`; Windows `GetExtendedTcpTable` and the process
43
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
44
+ per new connection (Node). On macOS the Python reader calibrates once per process with a
45
+ short-lived child of its own interpreter listening on a random `127.0.0.1` port; when the
46
+ table is restricted (macOS 27.2, FAA-267) the launchd installers also run `/usr/sbin/lsof
47
+ -nP -a -p <pid> -i4TCP -sTCP:LISTEN -Fn` on their own job's pid. That vouch shows the job listens on the port, not that it accepted
48
+ the connection (a more specific bind by root over a wildcard job would pass; contract CT-261). A token-free loopback door is not yet protected against
45
49
  another account's process (HA-3, FAA-227). An MCP client (Claude Code, Codex) sends the
46
50
  token it was registered with on every later connection without this check (the contract's
47
51
  residue "a coding agent's bearer"): when the port may have changed hands, rotate the token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.75.0",
3
+ "version": "0.76.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.75.0",
6
+ "version": "0.76.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ 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.75.0"
8
+ version: "0.76.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
11
11
  ---
@@ -586,6 +586,15 @@ def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
586
586
  _PLUGIN_SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".worktrees"}
587
587
 
588
588
 
589
+ def _secret_env_value(value: str) -> bool:
590
+ """An MCP server's environment value: path-aware only when it reads as a path (it starts as one, has two separators
591
+ and a lower-case word between them), as the kit's supervisor writers read one (FAA-266); else `secret_value`."""
592
+ pieces = re.split(r"[\\/]", value)
593
+ path = bool(re.match(r"(?:[/~.]|[A-Za-z]:[\\/])", value)) and len(pieces) >= 3 and any(
594
+ re.fullmatch(r"[a-z][a-z0-9_-]{2,}", x) for x in pieces)
595
+ return _secrets.secret_argument(value) if path else _secrets.secret_value(value)
596
+
597
+
589
598
  def _plugin_problems(root: Path) -> List[str]:
590
599
  """LC-19 (FAA-22) and LC-21 for the Claude Code plugins in the project: a plugin's stdio MCP server starts in
591
600
  every session that enables the plugin; serve it over HTTP from one resident process, or register it at project
@@ -614,7 +623,7 @@ def _plugin_problems(root: Path) -> List[str]:
614
623
  if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _secrets.secret_url(entry["url"])):
615
624
  problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
616
625
  env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
617
- if any(isinstance(v, str) and _secrets.secret_value(v) for v in env.values()):
626
+ if any(isinstance(v, str) and _secret_env_value(v) for v in env.values()): # path-aware for a path (FAA-266)
618
627
  problems.append("LC-21: plugin %s's MCP server %s writes a credential value in env; reference it instead" % (name, server))
619
628
  return problems
620
629
 
@@ -14,7 +14,7 @@ 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.75.0"
17
+ version: "0.76.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
@@ -419,9 +419,10 @@ this estate needs.
419
419
 
420
420
  ## Gotchas
421
421
 
422
- - `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong even if the Host check works.
422
+ - `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong.
423
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.
424
+ this OS account's listener. On `listener.owner` FAIL (`foreign-user`) send that port nothing; reason
425
+ `table-restricted`: `references/protocol.md`.
425
426
  - `launchctl bootstrap` right after `bootout` fails with I/O error 5: wait for the unload, then retry.
426
427
  - Under launchd `PATH` is bare. Pin the interpreter by absolute path; set `PATH`
427
428
  explicitly in the plist; never copy the whole shell environment into it.
@@ -59,7 +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
+ | 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.2`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
63
63
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
64
64
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
65
65
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
@@ -97,7 +97,11 @@ systemd and Task Scheduler do not poll yet, FAA-253).
97
97
  until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
98
98
  then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
99
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).
100
+ successful install; the error names it). Where the socket table hides other processes (macOS 27.2 under an
101
+ ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`; a listener it
102
+ cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
103
+ `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
104
+ vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
101
105
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
102
106
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
103
107
  and the one before it stay for rollback, older ones go (LC-11, LC-15).
@@ -224,6 +224,17 @@ and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's toke
224
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
225
  `this-account` refuses, `unknown` included.
226
226
 
227
+ macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too and was not measured) only its own sockets: sysctl lists this
228
+ process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
229
+ and an empty netstat is `unknown` with reason `table-restricted` (never another account's). A caller that started the
230
+ server itself may vouch with that pid: `lsof -p <pid>` asks the process, which the restriction leaves readable, and a
231
+ listener of this account's pid on the port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node
232
+ `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
233
+ refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
234
+ whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
235
+ IPv4 wildcard), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
236
+ the contract's ruling on vouching is CT-261.
237
+
227
238
  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
239
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
229
240
  `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
@@ -45,7 +45,7 @@ import http from 'node:http';
45
45
  import net from 'node:net';
46
46
  import os from 'node:os';
47
47
 
48
- export const PEER_OWNER_VERSION = '1.1.0+kit.1'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
48
+ export const PEER_OWNER_VERSION = '1.1.0+kit.2'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
49
49
  export const THIS_ACCOUNT = 'this-account';
50
50
  export const ANOTHER_ACCOUNT = 'another-account';
51
51
  export const SYSTEM = 'system';
@@ -58,6 +58,10 @@ export const WINDOWS_TIMEOUT_MS = 5000;
58
58
  export const LISTENER_TTL_MS = 1000;
59
59
  export const NETSTAT = '/usr/sbin/netstat';
60
60
  export const PS = '/bin/ps';
61
+ export const LSOF = '/usr/sbin/lsof';
62
+ // The reason a socket table hides other processes' sockets from this one (FAA-267): macOS 27.2 gives an ad-hoc-signed
63
+ // runtime an empty netstat.
64
+ export const TABLE_RESTRICTED = 'table-restricted';
61
65
  export const SERVER_NOT_OWN = 'server-not-own';
62
66
  export const SERVER_UNVERIFIED = 'server-unverified';
63
67
  export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
@@ -143,6 +147,7 @@ function netstatAddress(text) {
143
147
  // leading and the trailing columns, both counted from the header; a header without process:pid is unreadable.
144
148
  export function parseNetstat(text) {
145
149
  const lines = String(text).split(/\r?\n/);
150
+ if (!lines.some((line) => line.trim())) throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed nothing to this process`);
146
151
  const header = lines.findIndex((line) => line.includes('process:pid') && line.includes('Proto'));
147
152
  if (header < 0) throw new TableUnreadable('netstat-format: no process:pid column');
148
153
  const names = lines[header].split(/\s+/).filter(Boolean);
@@ -165,6 +170,18 @@ export function parseNetstat(text) {
165
170
  return rows;
166
171
  }
167
172
 
173
+ // `lsof -nP -a -p PID -i4TCP -sTCP:LISTEN -Fn`: the `n` lines, 127.0.0.1:47480, *:47480 or [::1]:47480.
174
+ export function parseLsofListeners(text) {
175
+ const found = [];
176
+ for (const line of String(text).split(/\r?\n/)) {
177
+ if (!line.startsWith('n') || !line.includes(':')) continue;
178
+ const at = line.lastIndexOf(':');
179
+ const port = line.slice(at + 1);
180
+ if (/^[0-9]+$/.test(port)) found.push([normHost(line.slice(1, at).replace(/^\[|\]$/g, '')), Number(port)]);
181
+ }
182
+ return found;
183
+ }
184
+
168
185
  // `ps -o uid= -p PID`: one number, or nothing when the process is gone.
169
186
  export function parsePsUid(text) {
170
187
  const t = String(text).trim();
@@ -497,10 +514,25 @@ export function refusal(who, port, expectPid = null) {
497
514
  return null;
498
515
  }
499
516
 
517
+ // macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on 127.0.0.1:<port>
518
+ // or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection). `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started
519
+ // `pid` itself (a launchd job it installed) may vouch with it.
520
+ export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine } = {}) {
521
+ if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
522
+ try {
523
+ const uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(pid)]));
524
+ if (uid === null || uid !== (mine ?? ownUid())) return false;
525
+ const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
526
+ return parseLsofListeners(out).some(([host, p]) => (host === '127.0.0.1' || host === '*') && p === port);
527
+ } catch {
528
+ return false;
529
+ }
530
+ }
531
+
500
532
  // A connected socket to host:port whose server end is this account's (and, with expectPid, that process's). The check
501
533
  // runs on the very socket the caller then writes on, so no listener can change between the check and the request.
502
534
  // Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
503
- export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, ...ask } = {}) {
535
+ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, vouchPid = null, ...ask } = {}) {
504
536
  return new Promise((resolveSocket, reject) => {
505
537
  const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
506
538
  const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
@@ -515,6 +547,11 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
515
547
  // A reader that throws (an injected one; the readers themselves answer unknown) fails closed.
516
548
  who = owner(UNKNOWN, { method: method(), reason: `check: ${err?.name ?? 'Error'}` });
517
549
  }
550
+ if (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED) && vouchPid
551
+ && await pidListens(vouchPid, port, ask)) {
552
+ // The table hides other processes (FAA-267); the process this caller started holds the listener.
553
+ who = owner(THIS_ACCOUNT, { uid: ask.ownUid ?? ownUid(), pid: vouchPid, method: 'lsof-pid' });
554
+ }
518
555
  const refused = refusal(who, port, expectPid);
519
556
  if (refused) {
520
557
  socket.destroy();
@@ -532,14 +569,15 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
532
569
  // when it is made. A refusal reaches the request as its 'error', a ListenerRefused. Free sockets do not keep the process
533
570
  // alive (Node unrefs them).
534
571
  export class CheckedAgent extends http.Agent {
535
- constructor({ expectPid = null, ask = {}, ...options } = {}) {
572
+ constructor({ expectPid = null, vouchPid = null, ask = {}, ...options } = {}) {
536
573
  super(options);
537
574
  this.expectPid = expectPid;
575
+ this.vouchPid = vouchPid;
538
576
  this.ask = ask;
539
577
  }
540
578
 
541
579
  createConnection(options, callback) {
542
580
  checkedSocket(Number(options.port), { host: options.host ?? '127.0.0.1', timeoutMs: options.timeout ?? 10000,
543
- expectPid: this.expectPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
581
+ expectPid: this.expectPid, vouchPid: this.vouchPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
544
582
  }
545
583
  }
@@ -177,6 +177,7 @@ export {
177
177
  taskUninstall,
178
178
  launchdPlist,
179
179
  launchdLoaded,
180
+ launchdPid,
180
181
  launchdOverride,
181
182
  launchdInstall,
182
183
  launchdUninstall,
@@ -9,7 +9,7 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
9
9
  import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
10
10
  import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
11
11
  import { URI } from './fabric-backups.mjs';
12
- import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
12
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
13
13
  import http from 'node:http';
14
14
 
15
15
  export const DEFAULT_EXIT_TIMEOUT = 15;
@@ -55,13 +55,22 @@ function noMarkerArgument(argv) {
55
55
  if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
56
56
  }
57
57
 
58
+ // An environment value: read path-aware (secretArgument) only when it reads as a path (it starts as one, has at least two
59
+ // separators and a lower-case word between them), so a release folder under Application Support is no key (FAA-266) while
60
+ // a base64 key whose `/` splits it stays one; any other value keeps the strict secretValue.
61
+ const secretEnvValue = (value) => {
62
+ const pieces = value.split(/[\\/]/);
63
+ const path = /^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) && pieces.length >= 3 && pieces.some((x) => /^[a-z][a-z0-9_-]{2,}$/.test(x));
64
+ return path ? secretArgument(value) : secretValue(value);
65
+ };
66
+
58
67
  function noSecretInSupervisorFile(argv, environment) {
59
68
  // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
60
69
  for (const arg of argv) {
61
70
  if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
62
71
  }
63
72
  for (const [name, value] of Object.entries(environment ?? {})) {
64
- if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
73
+ if (credentialName(name) || secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
65
74
  }
66
75
  }
67
76
 
@@ -264,7 +273,7 @@ export function launchdPlist(label, programArguments, { workingDirectory, stdout
264
273
  const env = { ...environment };
265
274
  for (const [name, value] of Object.entries(env)) {
266
275
  if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
267
- if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
276
+ if (secretEnvValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
268
277
  }
269
278
  for (const arg of argv) {
270
279
  if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
@@ -302,6 +311,14 @@ export function launchdLoaded(label, { run = defaultRun, platform = process.plat
302
311
  return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
303
312
  }
304
313
 
314
+ // The running pid of this account's launchd job `label`, or null (not running, or not readable).
315
+ export function launchdPid(label, { run = defaultRun, platform = process.platform, uid } = {}) {
316
+ const result = run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`]);
317
+ if (result?.status !== 0) return null;
318
+ const hit = /^\s*pid\s*=\s*([0-9]+)\s*$/m.exec(String(result.stdout ?? ''));
319
+ return hit ? Number(hit[1]) : null;
320
+ }
321
+
305
322
  const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
306
323
 
307
324
  /**
@@ -326,7 +343,7 @@ export class ForeignUserAnswer {
326
343
  // The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
327
344
  // (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
328
345
  // account's or its owner cannot be told.
329
- export async function wellKnownOf(origin) {
346
+ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
330
347
  const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
331
348
  if (!m) {
332
349
  try {
@@ -338,7 +355,7 @@ export async function wellKnownOf(origin) {
338
355
  }
339
356
  // A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
340
357
  // answer, so a poll leaves no connection open behind it.
341
- const agent = new CheckedAgent({ keepAlive: false });
358
+ const agent = new CheckedAgent({ keepAlive: false, vouchPid });
342
359
  return new Promise((resolve) => {
343
360
  const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/.well-known/fabric-service', agent,
344
361
  timeout: 2000 }, (res) => {
@@ -350,7 +367,9 @@ export async function wellKnownOf(origin) {
350
367
  res.on('error', () => resolve(null));
351
368
  });
352
369
  req.on('timeout', () => req.destroy());
353
- req.on('error', (err) => resolve(err instanceof ListenerRefused ? new ForeignUserAnswer(`foreign-user: ${err.message}`) : null));
370
+ req.on('error', (err) => resolve(err instanceof ListenerRefused
371
+ ? new ForeignUserAnswer(`${String(err.owner?.reason ?? '').startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : 'foreign-user'}: ${err.message}`)
372
+ : null));
354
373
  req.end();
355
374
  }).finally(() => agent.destroy());
356
375
  }
@@ -390,13 +409,18 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
390
409
  }
391
410
  const deadline = Date.now() + timeout * 1000;
392
411
  let foreign = null;
412
+ let vouchPid = null;
393
413
  while (Date.now() < deadline) {
394
- const doc = await fetchWellKnown(origin);
414
+ const doc = await fetchWellKnown(origin, { vouchPid });
395
415
  foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
416
+ if (foreign?.startsWith(TABLE_RESTRICTED)) vouchPid = launchdPid(label, opts); // only a restricted table needs it (FAA-267)
396
417
  if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
397
418
  await sleep(500);
398
419
  }
399
420
  // DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
421
+ if (foreign?.startsWith(TABLE_RESTRICTED)) {
422
+ throw new ServiceError(`${origin} could not be proved this service's: ${foreign} (DEC-0154 HA-2); this runtime sees no other process's sockets and the job's pid did not show the listener: run the installer from a runtime that sees them, or read the job's log.`);
423
+ }
400
424
  if (foreign) {
401
425
  throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
402
426
  }
@@ -354,6 +354,7 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
354
354
  **kwargs: Any) -> None:
355
355
  http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
356
356
  self.expect_pid = None
357
+ self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
357
358
 
358
359
 
359
360
  def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
@@ -1,6 +1,6 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.1: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
3
+ peer_owner 1.1.0+kit.2: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
4
  DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
5
  commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
6
6
  from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
@@ -15,7 +15,10 @@
15
15
  row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
16
16
  a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
17
17
  finds no row or several reads the table once more; ctypes is imported where it is used, so a
18
- Python without it reads netstat on macOS and imports the module everywhere.
18
+ Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
19
+ macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
20
+ `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
21
+ `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`).
19
22
  A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
20
23
  created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
21
24
  The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
@@ -64,6 +67,12 @@ addresses are `unknown` (`several-rows`), never a guess.
64
67
  accept) and the listener and both ends of the pair must read back with their exact addresses,
65
68
  this process's uid and its pid. A layout that fails calibration is not used again in that
66
69
  process.
70
+ - macOS 27.2 restricts both tables for an ad-hoc-signed interpreter (a Homebrew Python, measured on 2026-11-07 on a
71
+ host agent's Mac): sysctl lists only this process's own sockets and netstat prints nothing. The calibration therefore
72
+ also requires a child process's listener, and an empty netstat is `unknown` with reason `table-restricted`, never
73
+ another account's (FAA-267). A caller that started the server itself (an installer and its launchd job) may vouch
74
+ with that pid: `lsof -p PID` asks the process, which the restriction leaves readable, and `CheckedHTTPConnection`
75
+ accepts its listener on the port as this account's (`vouch_pid`). Apple's own `/usr/bin/python3` sees the tables.
67
76
  - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then
68
77
  `ps -o uid= -p PID` for the owner of the one row a question needs. Measured: about 20 ms over
69
78
  846 sockets, and it names the pids of root's listeners, which `lsof` run by an ordinary account
@@ -100,7 +109,7 @@ import time
100
109
  from typing import NamedTuple, Optional, Tuple
101
110
 
102
111
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
103
- PEER_OWNER_VERSION = "1.1.0+kit.1" # Observatory's 1.1.0 with the kit's differences above
112
+ PEER_OWNER_VERSION = "1.1.0+kit.2" # Observatory's 1.1.0 with the kit's differences above
104
113
 
105
114
  THIS_ACCOUNT = "this-account"
106
115
  ANOTHER_ACCOUNT = "another-account"
@@ -120,6 +129,11 @@ LISTENER_TTL = 1.0
120
129
 
121
130
  NETSTAT = "/usr/sbin/netstat"
122
131
  PS = "/bin/ps"
132
+ LSOF = "/usr/sbin/lsof"
133
+
134
+ #: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
135
+ #: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
136
+ TABLE_RESTRICTED = "table-restricted"
123
137
 
124
138
  #: Client codes of `own_connection` (ListenerRefused.code).
125
139
  SERVER_NOT_OWN = "server-not-own"
@@ -221,6 +235,8 @@ def parse_netstat(text: str) -> list[Row]:
221
235
  `process:pid` column is what is left between the leading and the trailing columns, both
222
236
  counted from the header. A header without `process:pid` (an older macOS) is unreadable."""
223
237
  lines = text.splitlines()
238
+ if not any(line.strip() for line in lines):
239
+ raise TableUnreadable(f"{TABLE_RESTRICTED}: netstat printed nothing to this process")
224
240
  header = next((i for i, line in enumerate(lines) if "process:pid" in line and "Proto" in line), None)
225
241
  if header is None:
226
242
  raise TableUnreadable("netstat-format: no process:pid column")
@@ -259,6 +275,18 @@ def _netstat_address(text: str) -> Optional[tuple[str, int]]:
259
275
  return None
260
276
 
261
277
 
278
+ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
279
+ """`lsof -nP -a -p PID -i4TCP -sTCP:LISTEN -Fn`: the `n` lines, `127.0.0.1:47480`, `*:47480` or `[::1]:47480`."""
280
+ found: list[tuple[str, int]] = []
281
+ for line in text.splitlines():
282
+ if not line.startswith("n") or ":" not in line:
283
+ continue
284
+ host, port = line[1:].rsplit(":", 1)
285
+ if port.isdigit():
286
+ found.append((norm_host(host.strip("[]")), int(port)))
287
+ return found
288
+
289
+
262
290
  def parse_ps_uid(text: str) -> Optional[int]:
263
291
  """`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
264
292
  text = text.strip()
@@ -468,10 +496,47 @@ def calibration_holds(rows: list[Row], server: tuple[str, int], client: tuple[st
468
496
  return (one(server, ("*", 0), True) and one(server, client, False) and one(client, server, False))
469
497
 
470
498
 
499
+ _CHILD_LISTENER = ("import socket,sys\ns=socket.socket();s.bind(('127.0.0.1',0));s.listen(1)\n"
500
+ "print(s.getsockname()[1],flush=True);sys.stdin.read()")
501
+
502
+
503
+ def _child_listener_seen(rows_of) -> Optional[bool]:
504
+ """Whether the table shows a listener of a child process of this one: False when it does not (the table shows
505
+ this process alone, FAA-267), None when no child could be started or did not answer within TIMEOUT_SECONDS (a
506
+ frozen or embedded host has no interpreter to start). The calibration trusts only True."""
507
+ if not sys.executable or getattr(sys, "frozen", False):
508
+ return None
509
+ try:
510
+ child = subprocess.Popen([sys.executable, "-I", "-c", _CHILD_LISTENER], stdin=subprocess.PIPE,
511
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
512
+ except OSError:
513
+ return None
514
+ try:
515
+ import select # POSIX: this reader runs on macOS alone
516
+ if not select.select([child.stdout], [], [], TIMEOUT_SECONDS)[0]:
517
+ return None
518
+ line = child.stdout.readline().strip()
519
+ if not line.isdigit():
520
+ return None
521
+ port = int(line)
522
+ return any(r.listening and r.local == ("127.0.0.1", port) and r.pid == child.pid for r in rows_of())
523
+ except (TableUnreadable, OSError, struct.error, ValueError):
524
+ return False
525
+ finally:
526
+ try:
527
+ child.stdin.close()
528
+ child.wait(timeout=TIMEOUT_SECONDS)
529
+ except (OSError, subprocess.TimeoutExpired):
530
+ child.kill()
531
+ child.wait()
532
+ child.stdout.close()
533
+
534
+
471
535
  def _calibrate_sysctl() -> bool:
472
536
  """Open a connected pair of this process's own (listen, connect, accept) and read it back
473
- (`calibration_holds`). A layout that does not hold is never trusted in this process, and
474
- `netstat` answers instead."""
537
+ (`calibration_holds`), then require a child process's listener in the table too: a table that shows this process
538
+ alone (FAA-267) would pass the first check and find nobody else. A layout that does not hold is never trusted in
539
+ this process, and `netstat` answers instead."""
475
540
  probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
476
541
  client = accepted = None
477
542
  try:
@@ -480,8 +545,10 @@ def _calibrate_sysctl() -> bool:
480
545
  client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
481
546
  accepted, _ = probe.accept()
482
547
  rows = parse_pcblist_n(_sysctl_blob())
483
- return calibration_holds(rows, _address(probe.getsockname()), _address(client.getsockname()),
484
- uid=_posix_uid(), pid=os.getpid())
548
+ if not calibration_holds(rows, _address(probe.getsockname()), _address(client.getsockname()),
549
+ uid=_posix_uid(), pid=os.getpid()):
550
+ return False
551
+ return _child_listener_seen(lambda: parse_pcblist_n(_sysctl_blob())) is True
485
552
  except (TableUnreadable, OSError, struct.error, ValueError):
486
553
  return False
487
554
  finally:
@@ -805,6 +872,23 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
805
872
  return None
806
873
 
807
874
 
875
+ def pid_listens(pid: int, port: int) -> bool:
876
+ """macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on
877
+ 127.0.0.1:<port> or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection).
878
+ `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that started `pid`
879
+ itself (a launchd job it installed) may vouch with it."""
880
+ if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
881
+ return False
882
+ try:
883
+ uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
884
+ if uid is None or uid != _posix_uid():
885
+ return False
886
+ out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
887
+ except TableUnreadable:
888
+ return False
889
+ return any(host in ("127.0.0.1", "*") and p == port for host, p in parse_lsof_listeners(out))
890
+
891
+
808
892
  class CheckedHTTPConnection(http.client.HTTPConnection):
809
893
  """An `http.client.HTTPConnection` whose every `connect()` checks the server end before
810
894
  returning. `http.client` connects again by itself (`auto_open`) when the server answered
@@ -812,9 +896,11 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
812
896
  different listener; so the check is in `connect()`, not once in `own_connection`. A refused
813
897
  connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
814
898
 
815
- def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0) -> None:
899
+ def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
900
+ vouch_pid: Optional[int] = None) -> None:
816
901
  super().__init__(host, port, timeout=timeout)
817
902
  self.expect_pid = expect_pid
903
+ self.vouch_pid = vouch_pid
818
904
 
819
905
  def set_tunnel(self, *args, **kwargs): # noqa: D401 - a tunnel would put a proxy's socket under the check
820
906
  raise ListenerRefused(SERVER_UNVERIFIED, "a tunnel would check the proxy, not the server; nothing was sent")
@@ -822,6 +908,10 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
822
908
  def connect(self) -> None:
823
909
  super().connect()
824
910
  owner = server_end(self.sock)
911
+ if (owner.kind == UNKNOWN and (owner.reason or "").startswith(TABLE_RESTRICTED) and getattr(self, "vouch_pid", None)
912
+ and pid_listens(self.vouch_pid, self.port)):
913
+ # The table hides other processes (FAA-267); the process this caller started holds the listener.
914
+ owner = Owner(THIS_ACCOUNT, _posix_uid(), self.vouch_pid, "lsof-pid")
825
915
  refused = refusal(owner, self.port, self.expect_pid)
826
916
  if refused is not None:
827
917
  self.close()
@@ -829,7 +919,7 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
829
919
 
830
920
 
831
921
  def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
832
- host: str = "127.0.0.1") -> CheckedHTTPConnection:
922
+ host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
833
923
  """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
834
924
  (and, with `expect_pid`, that process's). The check runs on the very connection the caller
835
925
  then sends on, so no listener can change between the check and the request, and it runs
@@ -839,7 +929,10 @@ def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: floa
839
929
  server-not-own another account's or the system's process accepted it
840
930
  server-unverified its owner could not be told (nothing is sent: fail closed)
841
931
  server-not-this-workspace this account's process, but not the pid expected
932
+
933
+ `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
934
+ (FAA-267).
842
935
  """
843
- conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout)
936
+ conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
844
937
  conn.connect()
845
938
  return conn
@@ -314,6 +314,7 @@ from fabric_supervisors import ( # noqa: F401
314
314
  _launchctl,
315
315
  _domain,
316
316
  launchd_loaded,
317
+ launchd_pid,
317
318
  fetch_well_known,
318
319
  _OVERRIDE_ROW,
319
320
  launchd_override,
@@ -53,6 +53,20 @@ def _no_marker_argument(argv: Sequence[str]) -> None:
53
53
  raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
54
54
 
55
55
 
56
+ _PATH_ANCHOR = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/])")
57
+ _PATH_WORD = re.compile(r"[a-z][a-z0-9_-]{2,}")
58
+
59
+
60
+ def _secret_env_value(value: str) -> bool:
61
+ """An environment value: read path-aware (`secret_argument`) only when it reads as a path (it starts as one, has at
62
+ least two separators and a lower-case word between them), so a release folder under Application Support is no key
63
+ (FAA-266) while a base64 key whose `/` splits it stays one; any other value keeps the strict `secret_value`."""
64
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
65
+ pieces = re.split(r"[\\/]", value)
66
+ path = bool(_PATH_ANCHOR.match(value)) and len(pieces) >= 3 and any(_PATH_WORD.fullmatch(x) for x in pieces)
67
+ return fabric_secrets.secret_argument(value) if path else fabric_secrets.secret_value(value)
68
+
69
+
56
70
  def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
57
71
  """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
58
72
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
@@ -60,7 +74,7 @@ def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dic
60
74
  if fabric_secrets.secret_argument(arg):
61
75
  raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
62
76
  for name, value in (environment or {}).items():
63
- if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
77
+ if fabric_secrets.credential_name(name) or _secret_env_value(str(value)):
64
78
  raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
65
79
 
66
80
 
@@ -263,7 +277,7 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
263
277
  for key, value in env.items():
264
278
  if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
265
279
  raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
266
- if fabric_secrets.secret_value(str(value)):
280
+ if _secret_env_value(str(value)): # path-aware only for a path: a release folder is no key (FAA-266)
267
281
  raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
268
282
  for arg in argv:
269
283
  if fabric_secrets.secret_argument(arg):
@@ -307,19 +321,37 @@ def launchd_loaded(label: str) -> bool:
307
321
  return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
308
322
 
309
323
 
310
- def _read_well_known(origin: str, timeout: float = 2.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
324
+ _PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
325
+
326
+
327
+ def launchd_pid(label: str) -> Optional[int]:
328
+ """The running pid of this account's launchd job ``label``, or None (not running, or not readable)."""
329
+ result = _launchctl("print", "%s/%s" % (_domain(), label))
330
+ if result.returncode != 0:
331
+ return None
332
+ for line in (result.stdout or "").splitlines():
333
+ hit = _PID_ROW.match(line)
334
+ if hit:
335
+ return int(hit.group(1))
336
+ return None
337
+
338
+
339
+ def _read_well_known(origin: str, timeout: float = 2.0,
340
+ vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
311
341
  """(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
312
342
  connection whose server end is this OS account's process (DEC-0154 HA-2): another account's, root's, or one whose
313
- owner cannot be told is `foreign-user`, never this service's answer."""
343
+ owner cannot be told is `foreign-user`, never this service's answer. `vouch_pid`, the pid of a launchd job this
344
+ caller started, proves the listener when the socket table hides other processes (FAA-267)."""
314
345
  parts = urllib.parse.urlsplit(origin)
315
346
  if parts.scheme == "http" and parts.hostname == "127.0.0.1" and parts.port:
316
- conn = _po.CheckedHTTPConnection("127.0.0.1", parts.port, timeout=timeout)
347
+ conn = _po.CheckedHTTPConnection("127.0.0.1", parts.port, timeout=timeout, vouch_pid=vouch_pid)
317
348
  try:
318
349
  conn.request("GET", "/.well-known/fabric-service", headers={"Host": "127.0.0.1:%d" % parts.port})
319
350
  response = conn.getresponse()
320
351
  return (json.loads(response.read().decode()) if response.status == 200 else None), None
321
352
  except _po.ListenerRefused as exc:
322
- return None, "foreign-user: %s" % exc
353
+ restricted = exc.owner is not None and (exc.owner.reason or "").startswith(_po.TABLE_RESTRICTED)
354
+ return None, "%s: %s" % (_po.TABLE_RESTRICTED if restricted else "foreign-user", exc)
323
355
  except (OSError, ValueError, http.client.HTTPException):
324
356
  return None, None
325
357
  finally:
@@ -392,14 +424,20 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
392
424
  else:
393
425
  raise ServiceError("launchctl bootstrap failed: %s" % last)
394
426
  deadline = time.time() + timeout
395
- foreign = None
427
+ foreign = vouch = None
396
428
  while time.time() < deadline:
397
- doc, foreign = _read_well_known(origin)
429
+ doc, foreign = _read_well_known(origin, vouch_pid=vouch)
430
+ if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
431
+ vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
398
432
  if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
399
433
  return doc
400
434
  time.sleep(0.5)
401
435
  if foreign:
402
436
  # DEC-0154 HA-2 and service.md's Install row: a listener of another account is never a successful install.
437
+ if foreign.startswith(_po.TABLE_RESTRICTED):
438
+ raise ServiceError("%s could not be proved this service's: %s (DEC-0154 HA-2); this interpreter sees no other "
439
+ "process's sockets and the job's pid did not show the listener: run the installer from "
440
+ "Apple's /usr/bin/python3, or read the job's log." % (origin, foreign))
403
441
  raise ServiceError("%s is not this service's: %s (DEC-0154 HA-2); the port is another OS account's or its owner "
404
442
  "cannot be told." % (origin, foreign))
405
443
  raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabric-service-kit",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "about": "The service kit's modules in dependency order: a module imports only modules listed before it. Written by fabric_kit.py build; edit the order and layers by hand, never the digests.",
5
5
  "python": [
6
6
  {
@@ -38,7 +38,7 @@
38
38
  "file": "fabric_peer_owner.py",
39
39
  "layer": "L0",
40
40
  "imports": [],
41
- "sha256": "3d66b1084c0b8b11ccfc4c66c1b32751ea237df03d864bfc0661d1a2e4665338"
41
+ "sha256": "628c8e56eb067ecb7c343a606f8c75661257d7f349c47d9628449850d41d1f78"
42
42
  },
43
43
  {
44
44
  "module": "fabric_crypto",
@@ -338,7 +338,7 @@
338
338
  "fabric_peer_owner",
339
339
  "fabric_secrets"
340
340
  ],
341
- "sha256": "bf5aac4034468f966cefae28dcbdca7f04f42fe73aefd1bac1b377cf4bda2b74"
341
+ "sha256": "1ebf842de248ff0f154939ae6c9d5d0229b3358aecaefea07a80ed1b8755ac70"
342
342
  },
343
343
  {
344
344
  "module": "fabric_service",
@@ -362,7 +362,7 @@
362
362
  "fabric_time",
363
363
  "fabric_usage"
364
364
  ],
365
- "sha256": "4b13f90aa26881fbfe0d9f3077fbb3552f3ce0a3c59279d6baa92df872867d8d"
365
+ "sha256": "73082f69844739708732f6331742e00f17d27385d3c3efa3331a1fd61b523595"
366
366
  },
367
367
  {
368
368
  "module": "fabric_profiles",
@@ -494,7 +494,7 @@
494
494
  "fabric_jsvalue",
495
495
  "fabric_peer_owner"
496
496
  ],
497
- "sha256": "e02b049b40eaabd994d93c7b084ee34ef3a78b5cbb61303b8e519b906af3c27a"
497
+ "sha256": "b11b9930c3f0ab911a9cd325086a4c07a5ed4c246799d290f6085245669f4994"
498
498
  },
499
499
  {
500
500
  "module": "fabric_openrouter",
@@ -542,7 +542,7 @@
542
542
  "file": "fabric-peer-owner.mjs",
543
543
  "layer": "L0",
544
544
  "imports": [],
545
- "sha256": "0e65d59bc1048a5635c8ac04e73fe4fd1f34094902ced1580859017e5ca5a991"
545
+ "sha256": "0d5a52385127546dea797e2dec36daeec6e05b04fdef04c1d8fd1ecf8db1a4e8"
546
546
  },
547
547
  {
548
548
  "module": "fabric-crypto",
@@ -841,7 +841,7 @@
841
841
  "fabric-peer-owner",
842
842
  "fabric-secrets"
843
843
  ],
844
- "sha256": "e33b2b8916aeed617eee6233e05bd637bb4ecd87064248fd7ea08fdfcd45d375"
844
+ "sha256": "de6468aa2876be437a374499dd084281a10d62c3fd23bd7c65285da3ee282ce2"
845
845
  },
846
846
  {
847
847
  "module": "fabric-service",
@@ -863,7 +863,7 @@
863
863
  "fabric-supervisors",
864
864
  "fabric-usage"
865
865
  ],
866
- "sha256": "297fef11ac76dc831d12d24c5581b7a93587c300c20664db703b3f924c0f9f1a"
866
+ "sha256": "2fec45eb56d4a9ddc479f05d99abda4d2bb324746e94c156d96157462b824de0"
867
867
  },
868
868
  {
869
869
  "module": "fabric-profiles",
@@ -6061,6 +6061,98 @@
6061
6061
  "fabric-openrouter.mjs": "6041f3ab30f516658e1dbab4897a651cc8509b2136cfa8e769802c6b40ec2f35",
6062
6062
  "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
6063
6063
  "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
6064
+ },
6065
+ "0.76.0": {
6066
+ "fabric_jsvalue.py": "052f1b40bab18600b3731dbdca27cbff17588c21d9458fa6241efd1340d0e09a",
6067
+ "fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
6068
+ "fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
6069
+ "fabric_secrets.py": "53c5df8d6f2c87433c288853bfaad9a43f544789bdf1e39bda8d3ff14485c89d",
6070
+ "fabric_peer_owner.py": "628c8e56eb067ecb7c343a606f8c75661257d7f349c47d9628449850d41d1f78",
6071
+ "fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
6072
+ "fabric_base.py": "f62bcc2ed9912660fa875d27a09f60867260cc4925f79873501fd9620be59611",
6073
+ "fabric_platform.py": "a9350dff55ef0fb3a23cdb4d0320a15868b15a8deb10d2eb74d72c58740c284b",
6074
+ "fabric_paths.py": "8a1ee742f80f436e9f28c61b4f049a9545de2a9e0d66bfc61faa6ee5d1ba3734",
6075
+ "fabric_estate_rules.py": "8b36a7f4108d9c2b1fb38c8bff344778c807bed631a99b4a563ecb0a49c10b1c",
6076
+ "fabric_estate.py": "0737ae1d1bb12586bae49de1f15ef1348bc207b5007cced9dad4c55bb5870874",
6077
+ "fabric_mandate.py": "fb19ce5359606bd6e3352ab02b2a12f49605ec6e5c49b55cce1f79c831325044",
6078
+ "fabric_delegation.py": "716f0ff7ac01095f54b24970beac1962128e8ff33026b243fc0467876142ab03",
6079
+ "fabric_share.py": "da36a466426fc29dd7dd40013167f3959a18375f8fedd228bf64f298cec1040c",
6080
+ "fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
6081
+ "fabric_package_schema.py": "e623f0408d38e3343ed66222fb812907c6c52e1a286eb1c1176ab2e64640c9ad",
6082
+ "fabric_package.py": "1f2b9e73b2b32a7e1e887d4b69f87c6e21b4d1227ad924a235334223e6dd51ca",
6083
+ "fabric_protocols.py": "077c6f03f3b887a2e22d9ce6b5bd3750199d71a29d4c82b1d5af8a615b509d93",
6084
+ "fabric_actions.py": "ae21d1199614c4f74d1dcd1e0a87107a9d52434cd9e2d64aa24a7b1e196fb723",
6085
+ "fabric_connections.py": "7de55cb7d808f756d650effabfaa12027d40a5bd5d2f6d448c32969d47529ecb",
6086
+ "fabric_a2a.py": "8b01502131215bb4de1c01866187c56e99b791543923ed8577535573db33b919",
6087
+ "fabric_page_links.py": "1b57ea573c550e2f90d1ec3edcc655917ceaa0db9dd1635fccf422ec3024d25e",
6088
+ "fabric_lock.py": "dfaf92a7086cbb097d832a305869b7964e2d2a9da0e3365c8ec1685a7bb4fef9",
6089
+ "fabric_usage.py": "5cc2e03ccc5b06e81987ed7bc3eead7a7e6cbf1a7cb8e05c6c64315eed9d91b6",
6090
+ "fabric_backups.py": "1f3f0d7ecf361b29e9101e07f1c53e5dc304d598bf26fd59ec89f16b9fed2420",
6091
+ "fabric_descriptor.py": "c08c91ca0960c694ed7677e7e9247d6307c7b63f37a7e614db022c080cd92ccc",
6092
+ "fabric_context.py": "6ae311ddc7217a4aff915e7ce2646486931acd128462b7bebe4a52bbb5fbb6c7",
6093
+ "fabric_auth.py": "74ccc10239b65a3567cec5213a7b344cbd88d6eee1fd1c5f7be1d6daf985993c",
6094
+ "fabric_events.py": "cd94b186e2a8f8cb4ad2b6bd44d83ec5021933075e28c5d655f387f16dff42b8",
6095
+ "fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
6096
+ "fabric_supervisors.py": "1ebf842de248ff0f154939ae6c9d5d0229b3358aecaefea07a80ed1b8755ac70",
6097
+ "fabric_service.py": "73082f69844739708732f6331742e00f17d27385d3c3efa3331a1fd61b523595",
6098
+ "fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
6099
+ "fabric_interop_rules.py": "9bdb7989e57b9d0b0d15eb9982d396499823a5e2c528230b71c67866c736e0f5",
6100
+ "fabric_interop.py": "d27e31834611a37cf2a117746f2f60fc187ccc48718eb546ff923c5d0fce7dea",
6101
+ "fabric_hub.py": "c261a81616c52f603eb28cc12ec4034dc479f0517328a65f0dc48d436cf0708b",
6102
+ "fabric_board_shapes.py": "0a0e8d3d40e33dfd8c457aedb85330b2ff25f8c49a33eb6cbd5a5cc8a5007f7e",
6103
+ "fabric_board_link.py": "052bc3c474ece835bb428c37ca864810d04e394d991ddb9c93989a7a825073a5",
6104
+ "fabric_board_slots.py": "92b065e3df935547cf46653245f1129e4a1f8b59b0a41e8fa9ffdc41596ee592",
6105
+ "fabric_board_session.py": "1fd3c3884f3044b56a40f78b33ff88dcac9272284f6681ea359c662879826f7e",
6106
+ "fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
6107
+ "fabric_board.py": "f86c40b8291f8c818f6924910f4022b013d5fd107abaf8825dd0aa6c5e418638",
6108
+ "fabric_http.py": "b11b9930c3f0ab911a9cd325086a4c07a5ed4c246799d290f6085245669f4994",
6109
+ "fabric_openrouter.py": "d06a40f83f2fb8bd441ab3485ae882f1fbdd9cd7a9c1ec175eb47cb8ae2cce81",
6110
+ "fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
6111
+ "fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
6112
+ "fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
6113
+ "fabric-secrets.mjs": "5822a7e25ecf2e87b5bab4f99c1074af7379d8b49e2784283b5d7d8c25022cde",
6114
+ "fabric-peer-owner.mjs": "0d5a52385127546dea797e2dec36daeec6e05b04fdef04c1d8fd1ecf8db1a4e8",
6115
+ "fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
6116
+ "fabric-base.mjs": "7804b2df7715dbbf9702a9b61b6b8cef2110f9a52b04cabd8fb0a66458d87512",
6117
+ "fabric-platform.mjs": "a8119d95ebba4d589a9e401f5d6fc8dda62d59d44e4c1ff811d97fac3771ac0c",
6118
+ "fabric-paths.mjs": "aa630d611e748a899e7043480f4a4e46b444f2e15ff945f30c2fbef21c1ceea5",
6119
+ "fabric-estate-rules.mjs": "7476be71174aa5b1026134f2b335abf647feaa55584e2f9de8618e3054aa8e1e",
6120
+ "fabric-estate.mjs": "e8a2e62d2313121e6cb502f6e6abad94d1d546a44b4d9b3ce2be29e7808e8cf3",
6121
+ "fabric-mandate.mjs": "b70f3de03a659e60bba7b5a0580b3582825cd7b8a8e30a28c79621919ec5b7bb",
6122
+ "fabric-delegation.mjs": "8f63a63aab9a8746b7f664720b87e9398137d01b8d50880974d5070038e414a0",
6123
+ "fabric-share.mjs": "bfb26581c21ad984c2a337da02429f9f3a165cc582f8ba48de4d3dfb7d163a59",
6124
+ "fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
6125
+ "fabric-package-schema.mjs": "cd28092375db0fc21ffab1f0bb7794f7f8979ee08c64d3b9faf4b3845357caa6",
6126
+ "fabric-package.mjs": "8cfb32eebbd4e1bcf28a6e4437344ae2dd54479b0e7c820f002e9f94ab9ecaad",
6127
+ "fabric-protocols.mjs": "a560b7d6eb0d26204c5536711c3dda9daae90cc91e07cdc75412e75c86272767",
6128
+ "fabric-actions.mjs": "06e42f5e24c96311d5417d0d1effa93479ef7128a7ab139903b2bcf59253f6be",
6129
+ "fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
6130
+ "fabric-a2a.mjs": "51b72fe1d80649d659fcad3a8f96370db3476c4a7b30cbabd8522c138be69346",
6131
+ "fabric-page-links.mjs": "a8834926e9d4bbf7c4857f551451d2ebf6ac8873bbccaaf89c5e30cf45b17ee6",
6132
+ "fabric-lock.mjs": "02cf833c97a07a8b3c788f9c93f70c4b9c9066efa25318dccf0038e0fc79717f",
6133
+ "fabric-usage.mjs": "1c4d18004639409624ac3516a02f2b60bbf55abdb612e9a96c37e83666eb87a7",
6134
+ "fabric-backups.mjs": "cfd8c9085e3d597a783099c2bd2ffe2f3faa0daaadab46dedd30523894cfeb06",
6135
+ "fabric-descriptor.mjs": "7c0ef7d245c037eb9e859ab481f6e66fb3e5ef2cf6d1e0a8d32b6d303097ca43",
6136
+ "fabric-context.mjs": "1112d695e872f9825cbc94f08484974bff3880087bc29b8be59f885f03e8f7be",
6137
+ "fabric-auth.mjs": "10ff37a39f9e3adb4d0f05db66c45f49b10eef582e9220c213907964109d7e52",
6138
+ "fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
6139
+ "fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
6140
+ "fabric-supervisors.mjs": "de6468aa2876be437a374499dd084281a10d62c3fd23bd7c65285da3ee282ce2",
6141
+ "fabric-service.mjs": "2fec45eb56d4a9ddc479f05d99abda4d2bb324746e94c156d96157462b824de0",
6142
+ "fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
6143
+ "fabric-interop-rules.mjs": "b4e386c359a48e49ffff8a842215760c18a7c98a0af4754a808a35fb2820d458",
6144
+ "fabric-interop.mjs": "989040aed33b2712a258244192bdf70e811a51076aa1b62f2adc2a6db051dbed",
6145
+ "fabric-hub.mjs": "73ab07f72320a50042f39b52893b563157be2ac94615614416df45a796eac988",
6146
+ "fabric-board-shapes.mjs": "c1db7b7bd0d58d82bb6d8dfbc3cd1547a256a333052d367e82ff1b3edf2804d7",
6147
+ "fabric-board-link.mjs": "6e7d51f4fd9730d93455f50dd30e97907b8b9e1ca65a1d69f036f2e8ec05cc0d",
6148
+ "fabric-board-slots.mjs": "fc77a3c6e62aa18575568bef4f703cce2edaba8cef648813ebcdbb21bdc27a11",
6149
+ "fabric-board-session.mjs": "18555962e5f6d18080a3e7971c4c6640452542f6042d07917e7ebc073721c415",
6150
+ "fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
6151
+ "fabric-board.mjs": "15fbb4441fcf7d2f285375b75de64ad6cffea129c515084c110245376b07775f",
6152
+ "fabric-http.mjs": "b83b4c0c208763d56c3eebda5fe9e4a46d595cd91227699bf6876e65a3c7f9b9",
6153
+ "fabric-openrouter.mjs": "6041f3ab30f516658e1dbab4897a651cc8509b2136cfa8e769802c6b40ec2f35",
6154
+ "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
6155
+ "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
6064
6156
  }
6065
6157
  },
6066
6158
  "data": {
@@ -5,7 +5,7 @@ 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. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.75.0"
8
+ version: "0.76.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
11
11
  ---
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.75.0"
21
+ version: "0.76.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -58,7 +58,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
58
58
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
59
59
 
60
60
 
61
- VERSION = "0.75.0"
61
+ VERSION = "0.76.0"
62
62
 
63
63
 
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",