@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.82.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 (56) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -3
  3. package/SECURITY.md +4 -2
  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/references/dashboard-links.md +9 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  50. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  52. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  56. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.6 · Fabric's button (Fabric Agent Contract DEC-0040, FB-1…FB-8, FL-1…FL-6)
1
+ /* fabric-dashboard-kit@1.1.7 · Fabric's button (Fabric Agent Contract DEC-0040, FB-1…FB-8, FL-1…FL-6)
2
2
  Load it same-origin, after fabric-dashboard.css, with the service it opens Fabric on:
3
3
  <script src="fabric-button.js" defer data-service="example-agent.default" data-service-name="Example Agent"
4
4
  data-project="project-a" data-project-name="Project A"></script>
@@ -11,7 +11,7 @@
11
11
  (function (root) {
12
12
  'use strict';
13
13
 
14
- var VERSION = '1.1.6';
14
+ var VERSION = '1.1.7';
15
15
  var COOKIE = 'fabric_button';
16
16
  var GET_FABRIC = 'https://passioncode.ai/fabric';
17
17
  var ABSENT_AFTER_MS = 1500;
@@ -37,8 +37,26 @@
37
37
  session: ['token', 'id'],
38
38
  code: ['login', 'auth', 'access', 'session']
39
39
  };
40
- var JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
41
- var SECRET_VALUE = /(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})/i;
40
+ // Whether /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/ matches anywhere, read in one pass (the
41
+ // contract's hasJwt, FAA-313): the expression backtracks from every `eyJ` of a long run, while every `eyJ` of one run
42
+ // shares that run's end, so only its first is tried.
43
+ var URL_BASE64 = /[A-Za-z0-9_-]/;
44
+ function runEnd(text, from) { while (from < text.length && URL_BASE64.test(text.charAt(from))) from += 1; return from; }
45
+ function hasJwt(text) {
46
+ var at = text.indexOf('eyJ');
47
+ while (at >= 0) {
48
+ var one = runEnd(text, at + 3);
49
+ if (one - at - 3 >= 2 && text.charAt(one) === '.') {
50
+ var two = runEnd(text, one + 1);
51
+ if (two - one - 1 >= 2 && text.charAt(two) === '.') return true;
52
+ }
53
+ at = text.indexOf('eyJ', one);
54
+ }
55
+ return false;
56
+ }
57
+ // The contract's SECRET_VALUE at the pin (DEC-0151), as the kit's fabric-secrets.mjs writes it (FAA-314, kit 1.1.7):
58
+ // `Bearer ` only at a value's start, a provider key's prefix in its canonical case after a boundary.
59
+ var SECRET_VALUE = /(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)(?:sk-(?=[A-Za-z0-9_-]{0,255}?\d)(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))/;
42
60
  var LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
43
61
 
44
62
  function nameWords(name) {
@@ -60,7 +78,7 @@
60
78
  function longSecret(value) {
61
79
  return (value.match(LONG_RUN) || []).some(function (run) { return /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run); });
62
80
  }
63
- function secretValue(value) { return JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || longSecret(value); }
81
+ function secretValue(value) { return hasJwt(value) || SECRET_VALUE.test(value) || longSecret(value); }
64
82
  function decodeKeepingPlus(raw) { try { return decodeURIComponent(raw); } catch (e) { return raw; } }
65
83
  function firstKey(text) { var it = new URLSearchParams(text).keys().next(); return it.done ? '' : it.value; }
66
84
  function rawValues(query) {
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.6 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
1
+ /* fabric-dashboard-kit@1.1.7 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
2
2
  A Fabric service dashboard shown inside a host pane: usable from 360 CSS px up, navigation always
3
3
  reachable, wide content in its own scroll box, one vertical scroller, appearance from the host.
4
4
  Copy this file next to fabric-dashboard.js; do not edit it in place. Put the page's own rules in
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.6 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
1
+ /* fabric-dashboard-kit@1.1.7 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
2
2
  Load it same-origin with <script src="fabric-dashboard.js" defer></script>: no inline script, no
3
3
  eval, nothing from a CDN. It applies the host's appearance (ED-6, ED-8), folds navigation that
4
4
  does not fit into a More menu (ED-3), makes wide boxes keyboard-reachable (ED-4) and keeps
@@ -8,7 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  var KIT = 'fabric-dashboard-kit';
11
- var VERSION = '1.1.6';
11
+ var VERSION = '1.1.7';
12
12
  /* fabric-dashboard-kit:theme-ids:begin */
13
13
  var THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
14
14
  /* fabric-dashboard-kit:theme-ids:end */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabric-dashboard-kit",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "profile": "fabric-dashboard/0.1",
5
5
  "decision": "DEC-0036",
6
6
  "declarationKey": "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1",
@@ -15,9 +15,9 @@
15
15
  "contrast"
16
16
  ],
17
17
  "files": {
18
- "fabric-dashboard.css": "7a90bbe1f1a2170df5dac0f596a3f302167bef9a7243457bb21dd0eb34f4d359",
19
- "fabric-dashboard.js": "146ec9a0fd50af2df3245c02ec7dfba75b7d4e8cb5476fedb5ef727869fef1cc",
20
- "fabric-button.js": "60c2b6d44571213e0237c29a94a4cf220f9de3efcfa697ac6a33777d1ad25fd6",
18
+ "fabric-dashboard.css": "549c1c1546bc9f8d2c1a579e7ad1035d009c94c3324b91d89d892afc437d5800",
19
+ "fabric-dashboard.js": "fa966a8f3b228391c2dae33674887a0d44b102cb58e42dbe6331f08bab57b391",
20
+ "fabric-button.js": "220c5c9719a9f6e35851d8748e2abb1d6606ffdb9fc43f3aff2b01f34637d925",
21
21
  "themes.json": "511839f1a1a83eb37fadfd132791474497d5c28c5091700b9aa31ad076e359b5"
22
22
  },
23
23
  "history": {
@@ -59,6 +59,11 @@
59
59
  "fabric-dashboard.css": "7a90bbe1f1a2170df5dac0f596a3f302167bef9a7243457bb21dd0eb34f4d359",
60
60
  "fabric-dashboard.js": "146ec9a0fd50af2df3245c02ec7dfba75b7d4e8cb5476fedb5ef727869fef1cc",
61
61
  "fabric-button.js": "60c2b6d44571213e0237c29a94a4cf220f9de3efcfa697ac6a33777d1ad25fd6"
62
+ },
63
+ "1.1.7": {
64
+ "fabric-dashboard.css": "549c1c1546bc9f8d2c1a579e7ad1035d009c94c3324b91d89d892afc437d5800",
65
+ "fabric-dashboard.js": "fa966a8f3b228391c2dae33674887a0d44b102cb58e42dbe6331f08bab57b391",
66
+ "fabric-button.js": "220c5c9719a9f6e35851d8748e2abb1d6606ffdb9fc43f3aff2b01f34637d925"
62
67
  }
63
68
  }
64
69
  }
@@ -37,7 +37,8 @@ refuses it with `invalid_arguments` (FAA-244).
37
37
  binding credential, shown once, goes into `credential_file` (0600, atomic) and never into the answer, a log or argv.
38
38
  Keep the file in the service's data directory or the operator's vault.
39
39
  3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
40
- checks the pid it names is alive, tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
40
+ checks the pid it names is alive (a process of another OS account under that id is `HubError` code `not-a-hub`,
41
+ retryable, saying not verified, as the session hooks say it, FAA-304; none at all is `not-running`), tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
41
42
  another OS account's process, or one whose owner cannot be told, is `HubError` code `foreign-user`, retryable, with
42
43
  nothing sent; a restricted macOS table, which a retry never clears, is `data.reason` `table-restricted` and not
43
44
  retryable, protocol.md, FAA-272), and only then sends the credential (a session already running carries that refusal as `BoardError` code
@@ -403,7 +404,8 @@ Python (`fabric_hub`): `fabric_root(platform, env)`, `locate_hub(root=None, aliv
403
404
  `read_door_token(location)`, `read_credential(path)`, `HubClient(root=None, *, alive, timeout, estate, post)` with
404
405
  `request_board_access(agent_id, project_id, capabilities, reason)`, `poll_access(request_id, poll_secret,
405
406
  credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `call_door(tool, args)`,
406
- `call_binding(tool, args, credential_file)`; `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
407
+ `call_binding(tool, args, credential_file)`; `pid_alive(pid)` answers True, False, or None for another OS account's
408
+ process (not verified); `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
407
409
  message, retryable, data)`.
408
410
 
409
411
  Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
@@ -105,7 +105,8 @@ service. Load it after the stylesheet, with ids only:
105
105
  Fabric* on a Russian page, or the page's `data-label-open`);
106
106
  `--ink` on `--panel` with an `--edge` boundary, the pairs the themes are measured on.
107
107
  - **Links (FL-1…FL-6):** built with the kit's port of the contract's `buildFabricLink`; a value outside the grammar
108
- (or one shaped like a credential) means no button, never a bad link. A press opens a view in Fabric and starts
108
+ (or one shaped like a credential, by the contract's DEC-0151 detector as the kit's `fabric-secrets.mjs` reads it, since
109
+ 1.1.7) means no button, never a bad link. A press opens a view in Fabric and starts
109
110
  nothing (FB-8).
110
111
  - **The host (FB-6):** when the host's `fabric_button` cookie says `owner=host`, the page hides its own button.
111
112
  - **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"`, or when the page is still
@@ -148,6 +149,16 @@ vertically by the viewport or a clipping ancestor; ED-4 is `NOT_RUN` on a page p
148
149
  the scroll position: say so in the report. A `NOT_RUN` is not a pass. Exit codes: 0 when no rule fails and at least
149
150
  one passed, 1 on a `FAIL`, 2 on a usage error, 3 when no rule was measured (every rule `NOT_RUN`, since 0.44.0).
150
151
 
152
+ Since 0.81.0 the probe opens a loopback page (and asks a local service for its login code) only after it reads the
153
+ page's listener from the socket table as this OS account's (DEC-0154 PL-3, HA-2; FAA-260). On a Mac whose socket
154
+ table is restricted for the runtime that runs the probe (macOS 27.2 shows an ad-hoc-signed Node, such as Homebrew's,
155
+ nothing through `netstat`; DEC-0155 RT-1) the listener reads `unknown` (`table-restricted`), so every rule is
156
+ `NOT_RUN` and the probe exits 3, whatever the page. That is not a pass and not the page's fault: say so in the report,
157
+ with the reason the probe printed. The probe does not vouch for the listener by the service's pid, since only a
158
+ caller that started the process may (DEC-0155 RT-3, the installers' route), and the well-known `process.pid` proves
159
+ nothing. To measure the page, run the probe with a Node whose table reads (an Apple-signed or Developer ID one), or
160
+ on a host without the restriction (FAA-318).
161
+
151
162
  ## Upgrading an existing dashboard
152
163
 
153
164
  A session that starts (or is cleared) in a repository that owns a service, whose dashboard has no
@@ -166,7 +177,9 @@ In the middle of an unrelated task, finish that first or record the upgrade as a
166
177
  3. A page with an older kit: `dashboard_kit.py install <dir>`. A `changed` copy is refused; move the
167
178
  edits into the page's own files, then install (or `--force` when the operator says the edits go).
168
179
  4. Raise `kit` in the declaration (`dashboard_extension()` does it), restart the service, and run
169
- both probes above. Report the layout table with every `NOT_RUN`.
180
+ both probes above. Report the layout table with every `NOT_RUN`. On a Mac with a restricted socket
181
+ table the layout probe measures nothing since 0.81.0 (every rule `NOT_RUN`, exit 3, the reason
182
+ `table-restricted`; *Proving it* above): report that as not measured, never as a pass.
170
183
 
171
184
  ## When something is missing
172
185
 
@@ -123,7 +123,15 @@ if_absent policy, the same device and `"listener_owner": "this-account"`: the ki
123
123
  the host read for the listener on http_url's port (`fabric_peer_owner`'s
124
124
  `this-account`, `another-account`, `system`, `unknown`, `none`, or
125
125
  `check_service.py`'s `foreign-user`); any other, or none given, refuses the
126
- browser (DEC-0154 PL-3, FAA-251). The native link does not take it: Fabric
126
+ browser (DEC-0154 PL-3, FAA-251). The host reads that kind right before it opens
127
+ the page with the adapter's
128
+ `python3 building-fabric-services/scripts/fabric_peer_owner.py listener <port>`
129
+ (or `node building-fabric-services/scripts/fabric-peer-owner.mjs listener <port>`),
130
+ which prints it on one line (`--json` prints the whole owner) and sends nothing
131
+ to the port (FAA-315). Where the socket table is restricted (macOS 27.2 for an
132
+ ad-hoc-signed Python or Node, DEC-0155 RT-4) only this account's listener on
133
+ exactly `127.0.0.1` reads `this-account`; a wildcard or `::1` bind reads
134
+ `unknown` (`table-restricted`), and the browser is refused. The native link does not take it: Fabric
127
135
  Dashboards checks its own listener. Contract DEC-0123 PL-3 also allows a browser
128
136
  on `unsupported` (a system the host does not ship for); the gate treats
129
137
  `unsupported` as no fallback, by the operator's rule that a browser opens only on a
@@ -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`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.5`; Observatory's 1.3.x libproc proof is FAA-292), 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` |
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`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.6`; Observatory's 1.3.x libproc proof is FAA-292), 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
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
64
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
@@ -79,7 +79,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
79
79
  | L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
80
80
  | L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
81
81
  | L3 | `fabric_load` | the call queue and the load report of DEC-0104 (FAA-289): `job_caller_key`, `next_queued_job`, `admit_job`, `queue_status_message`, `read_queue_position`, FAC-SEM-131 to 133, the `service-load/1` and `extensions/load/0.1` shapes, `load_extension`; imports `fabric_connections` for ajv's `date-time` ([interop](interop.md#a-nodes-call-queue)) |
82
- | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
82
+ | L3 | `fabric_lock` | the instance lock, its watch where the lock has no path-keyed name (macOS, FAA-303), and the supervisor marker (LC-01, DEC-0057) |
83
83
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
84
84
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
85
85
  | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document, the loopback HTTP server that refuses another OS account's peer before reading it (`LoopbackHTTPServer`, Node `loopbackServer`; FAA-227) |
@@ -106,7 +106,8 @@ systemd and Task Scheduler do not poll yet, FAA-253).
106
106
  then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
107
107
  (DEC-0154 HA-2: another account's listener, or one whose owner cannot be told, is `foreign-user` and never a
108
108
  successful install; the error names it). Where the socket table hides other processes (macOS 27.2 under an
109
- ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`; a listener it
109
+ ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`, for a listen on
110
+ exactly `127.0.0.1` alone (DEC-0155 RT-3; a wildcard bind is never vouched for since FAA-312); a listener it
110
111
  cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
111
112
  `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
112
113
  vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
@@ -381,6 +382,26 @@ holds the same name beside its file lock, so the two kits exclude each other eve
381
382
  name needs Node 22 or later: Node 18 and early 20 collapse every abstract socket name into one, and later 20 pads it so
382
383
  no Python copy or probe sees it, so the Node kit refuses to start there with a sentence naming Node 22 (FAA-72).
383
384
 
385
+ **A lock that moves under its holder (FAA-303).** `acquire` checks that the inode it locked is the file at the path
386
+ (FAA-293), which closes the race between two copies starting at once. A folder swapped under a copy that already holds
387
+ the lock (a data import replacing the data folder) is a second case. On Linux and Windows the path-keyed name above
388
+ still keeps a second copy out. On macOS the lock is `flock` (Python) or `O_EXLOCK` (Node), both on an inode, so a
389
+ second copy locks the new file and starts. There `hold_single_instance` / `holdSingleInstance` start a watch on the
390
+ held lock (`InstanceLock.watch` / `InstanceLock#watch`): every `LOCK_WATCH_SECONDS` (2 s; one `stat` and one `fstat`)
391
+ the holder compares the inode it locked with the file at the path, and once the path names another file (the imported
392
+ folder's lock, or the one a second copy made) it stops at once with 75 and one sentence naming the path
393
+ (`lost_lock_message` / `lostLockMessage`), so the copy started on the new folder runs alone. The two copies overlap for
394
+ at most one interval. The stop is abrupt on purpose (`stop_lost_holder` / `stopLostHolder`: `os._exit`,
395
+ `process.exit`): a graceful shutdown would write the old copy's state into the folder now at the path, which is not the
396
+ one it read. Under a supervisor the stopped copy is restarted and takes the new folder's lock, or waits as above while
397
+ another copy holds it. A path that is gone or cannot be
398
+ read now (a folder removed, a permission, an I/O error) is read as nothing there yet and tried again next time: a
399
+ second copy has to make a file there to start, and the watch then sees that file. `release` ends the watch. Pass
400
+ `on_lost` / `onLost` to `hold_single_instance` / `holdSingleInstance` (or call `watch` yourself on an `InstanceLock`) to
401
+ react otherwise. A path-keyed name on macOS was weighed and left: it needs a file outside the data folder, in a folder
402
+ the system never cleans and every copy of every user can write, which the standard library cannot promise; and a
403
+ watch also stops a holder that would otherwise go on writing stale state into the imported folder.
404
+
384
405
  **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
385
406
  token files*): the kits write a protected list: the user, SYSTEM and Administrators, set before the
386
407
  token's bytes go in, any entry already there dropped. On read, `windows_token_acl_problem` /
@@ -232,15 +232,21 @@ on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID
232
232
 
233
233
  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
234
234
  process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
235
- and an empty netstat is `unknown` with reason `table-restricted` (never another account's). A caller that started the
235
+ and a restricted table is `unknown` with a reason beginning `table-restricted` (never another account's): DEC-0155 RT-1
236
+ reads so a netstat that prints nothing or only its header, one that fails or exits non-zero, a table holding no row of
237
+ another uid (sysctl) or of another process (netstat), and a macOS table without the reader's own connection (FAA-312;
238
+ Linux and Windows keep `no-row`). Under it `listener(port)` reads this account's listener as its own only on exactly
239
+ `127.0.0.1` (RT-4). `fabric_peer_owner.py listener <port>` and `fabric-peer-owner.mjs listener <port>` print the kind
240
+ (`--json` the whole owner; FAA-315). A caller that started the
236
241
  server itself may vouch with that pid: `lsof -p <pid>` asks the process, which the restriction leaves readable, and a
237
242
  listener of this account's pid on the port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node
238
243
  `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
239
244
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
240
- 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
241
- IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDaemon's), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
242
- the pin's ruling on vouching is DEC-0155 RT-3 (the contract's CT-261), which vouches only for a listen on exactly
243
- `127.0.0.1`: the kit's IPv4 wildcard vouch for a LaunchAgent departs from it until a later release (filed by FAA-258).
245
+ whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on exactly `127.0.0.1`
246
+ on the port, not that it accepted this connection. Since FAA-312 every vouch follows DEC-0155 RT-3: a wildcard or `::1`
247
+ listener is never vouched for (`lsof` prints an IPv6-only wildcard as `*:<port>` too), and a reader whose real and
248
+ effective uid differ vouches for nothing. The accepted socket itself (RT-2, libproc or `lsof` of established sockets)
249
+ is not read yet (FAA-292; the repository's `docs/evidence/specs/2026-11-12-faa-292-libproc-decision.md`).
244
250
 
245
251
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
246
252
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
@@ -28,6 +28,24 @@ CREDENTIAL_PAIRS = {
28
28
  'session': {'token', 'id'},
29
29
  'code': {'login', 'auth', 'access', 'session'}}
30
30
  JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
+ URL_BASE64_RUN = re.compile(r'[A-Za-z0-9_-]*')
32
+
33
+
34
+ def has_jwt(text):
35
+ # Whether JWT_SHAPE matches anywhere, read in one pass (the contract's hasJwt, FAA-313): the expression backtracks
36
+ # from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one run shares that run's end, so only its
37
+ # first is tried. The kit's fabric_secrets.has_jwt reads the same way; this file stands alone, so it has its own.
38
+ at = text.find('eyJ')
39
+ while at >= 0:
40
+ one = URL_BASE64_RUN.match(text, at + 3).end()
41
+ if one - at - 3 >= 2 and text[one:one + 1] == '.':
42
+ two = URL_BASE64_RUN.match(text, one + 1).end()
43
+ if two - one - 1 >= 2 and text[two:two + 1] == '.':
44
+ return True
45
+ at = text.find('eyJ', one)
46
+ return False
47
+
48
+
31
49
  # The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key with its
32
50
  # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
33
51
  # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
@@ -58,7 +76,7 @@ def credential_name(name):
58
76
  def secret_value(value):
59
77
  long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
60
78
  for run in LONG_RUN.findall(value))
61
- return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
79
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret)
62
80
 
63
81
 
64
82
  def carries_credential(text):
@@ -825,13 +825,15 @@ class Probe:
825
825
  out = subprocess.run(["launchctl", "print", "%s/%s" % (domain, life.get("label"))], capture_output=True, text=True)
826
826
  match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
827
827
  served = ((self.wk or {}).get("process") or {}).get("pid")
828
+ # a LaunchDaemon's leftover LaunchAgent runs in its UserName's gui/<uid> (FAA-317)
829
+ second = other_domain_copy(life.get("label"), domain, plist.get("UserName")) if out.returncode == 0 and match else None
828
830
  if out.returncode != 0:
829
831
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
830
832
  elif not match:
831
833
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
832
- elif other_domain_copy(life.get("label"), domain): # loaded in both domains: two copies (FAA-286 item 6)
834
+ elif second: # loaded in both domains: two copies (FAA-286 item 6)
833
835
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is loaded in %s and in %s: two copies; uninstall one"
834
- % (life.get("label"), domain, other_domain_copy(life.get("label"), domain)))
836
+ % (life.get("label"), domain, second))
835
837
  else:
836
838
  same = int(match.group(1)) == served
837
839
  self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
@@ -18,7 +18,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
21
- from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
21
+ from fabric_secrets import JS_SPACE, SECRET_VALUE, has_jwt, raw_values, secret_argument, secret_url, secret_value # noqa: E402
22
22
  from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
23
23
  import check_service_channel_agent_schema as cas # noqa: E402
24
24
 
@@ -208,9 +208,9 @@ def read_as_url(read: str) -> bool:
208
208
 
209
209
 
210
210
  def _url_reading(read: str) -> bool:
211
- """secretUrl where read_as_url admits the head, secretArgument otherwise. The kit's secret_url also reads a URL the
212
- WHATWG parser refuses (the safe direction for its own callers); the contract's `new URL` throws there and finds only
213
- what secretArgument finds, so a refused URL is read by secret_argument alone here."""
211
+ """secretUrl where read_as_url admits the head, secretArgument otherwise. A URL the WHATWG parser refuses makes the
212
+ contract's `new URL` throw, which finds only what secretArgument finds, so such a URL is read by secret_argument
213
+ alone: url_parts answers None for it here, as the kit's secret_url reads it as an argument only since FAA-309."""
214
214
  if not read_as_url(read):
215
215
  return secret_argument(read)
216
216
  return secret_argument(read) or (url_parts(read) is not None and secret_url(read))
@@ -222,7 +222,7 @@ def shaped_read(read: str) -> bool:
222
222
 
223
223
  def _key_shaped(text: str) -> bool:
224
224
  read = head(text)
225
- return bool(JWT_SHAPE.search(read) or SECRET_VALUE.search(read))
225
+ return bool(has_jwt(read) or SECRET_VALUE.search(read))
226
226
 
227
227
 
228
228
  def plain_name(name: str) -> bool:
@@ -397,6 +397,8 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
397
397
  return None
398
398
  if at and hostport == "":
399
399
  return None
400
+ if host == "" and colon > bracket:
401
+ return None # an empty host ended by `:`, with an empty port or a port: `new URL` throws
400
402
  if host == "" and special and lowered != "file":
401
403
  return None
402
404
  if host and not _host_ok(host, special):
@@ -135,20 +135,31 @@ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
135
135
  import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
136
136
 
137
137
 
138
- def other_domain_copy(label: object, domain: str) -> Optional[str]:
139
- """The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None."""
140
- other = ("gui/%d" % getattr(os, "getuid", lambda: 0)()) if domain == "system" else "system"
141
- try:
142
- out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True, timeout=10)
143
- except (OSError, subprocess.SubprocessError):
144
- return None
145
- return other if out.returncode == 0 else None
138
+ def other_domain_copy(label: object, domain: str, user_name: object = None) -> Optional[str]:
139
+ """The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None. For a
140
+ LaunchDaemon (`system`) that is a gui/<uid> domain: first the one of its plist's UserName (`user_name`), where a
141
+ leftover LaunchAgent of a dedicated account runs (FAA-317), then the probe's own."""
142
+ if domain == "system":
143
+ uids = [_sup._account_uid(user_name)] if isinstance(user_name, str) and user_name else []
144
+ uids.append(getattr(os, "getuid", lambda: 0)())
145
+ others = ["gui/%d" % uid for i, uid in enumerate(uids) if uid is not None and uid not in uids[:i]]
146
+ else:
147
+ others = ["system"]
148
+ for other in others:
149
+ try:
150
+ out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True,
151
+ timeout=10)
152
+ except (OSError, subprocess.SubprocessError):
153
+ continue
154
+ if out.returncode == 0:
155
+ return other
156
+ return None
146
157
 
147
158
 
148
159
  def in_launch_daemons(plist_path: Path) -> bool:
149
- """Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278)."""
150
- folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
151
- return folder.lower() == str(_sup.SYSTEM_DAEMONS).lower() # the supervisors' own, which a test may point elsewhere
160
+ """Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278): the
161
+ supervisors' own rule, imported (FAA-317), which a test may point elsewhere through their SYSTEM_DAEMONS."""
162
+ return _sup._in_daemons(plist_path)
152
163
 
153
164
 
154
165
  def plist_problems(plist: dict, label: object, token: object = None, *, system: bool = False,
@@ -3,7 +3,7 @@
3
3
  // connectionsExtension builds the `extensions` entry a service declares, `state` from its own checks and its degraded[].
4
4
  // Part of the Node service kit (FAA-62, layer L3); the twin of fabric_connections.py. Node 20+, no dependency.
5
5
  import { ServiceError } from './fabric-base.mjs';
6
- import { BEARER_INSIDE, JWT_SHAPE, longSecret, secretUrl } from './fabric-secrets.mjs';
6
+ import { BEARER_INSIDE, hasJwt, longSecret, secretUrl } from './fabric-secrets.mjs';
7
7
  import { isObject } from './fabric-jsvalue.mjs';
8
8
 
9
9
  export const CONNECTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1';
@@ -112,8 +112,9 @@ const longRun = (value) =>
112
112
  const cut = at < 0 ? -1 : piece.indexOf('://', at);
113
113
  return longSecret(cut < 0 ? piece : piece.slice(0, cut));
114
114
  });
115
+ // hasJwt reads a JWT in one pass, where JWT_SHAPE backtracks from every `eyJ` of a long run (FAA-313).
115
116
  const credential = (value, runs) =>
116
- JWT_SHAPE.test(value) || KEY_SHAPE.test(value) || BEARER_INSIDE.test(value) || (runs && longRun(value)) || urlsIn(value).some(secretUrl);
117
+ hasJwt(value) || KEY_SHAPE.test(value) || BEARER_INSIDE.test(value) || (runs && longRun(value)) || urlsIn(value).some(secretUrl);
117
118
 
118
119
  /** What the schema cannot state: an id named twice, a credential in any string member. */
119
120
  function beyondShape(list) {
@@ -9,6 +9,8 @@
9
9
  // effects: { 'example.write': 'draft' }, issuer: wk.auth.delegation.issuer });
10
10
  // const verdict = await gate(toolName, req.headers); // before the tool runs
11
11
  // if (!verdict.ok) return refusalToolResult(verdict);
12
+ //
13
+ // Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
12
14
 
13
15
  import crypto from 'node:crypto';
14
16
  import fs from 'node:fs';
@@ -18,7 +18,7 @@ import { ROUTES } from './fabric-usage.mjs';
18
18
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
19
19
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
20
20
 
21
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.6';
21
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.7';
22
22
 
23
23
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
24
24
 
@@ -36,7 +36,10 @@ const instant = (value, name) => {
36
36
  const reasonOk = (reason, what) => {
37
37
  if (!HOLD_REASONS.includes(reason)) throw new ServiceError(`A ${what}'s reason is one of ${HOLD_REASONS.join(', ')}.`);
38
38
  };
39
- const squash = (text) => String(text).split(/\s+/).filter(Boolean).join(' ');
39
+ // One run of ECMAScript's whitespace, written out as fabric_secrets.JS_SPACE is, the class both twins squash with
40
+ // (FAA-316): Python's str.split() also takes U+001C to U+001F and U+0085 and not U+FEFF.
41
+ const SPACES = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
42
+ const squash = (text) => String(text).replace(SPACES, ' ').replace(/^ | $/g, '');
40
43
 
41
44
  /** A held job's statusMessage; the message is shortened, never refused, to fit 500 characters. */
42
45
  export function waitingMessage(reason, since, until = null, message = null) {
@@ -50,7 +53,7 @@ export function waitingMessage(reason, since, until = null, message = null) {
50
53
  const squashed = squash(message);
51
54
  const room = MAX_STATUS_MESSAGE - head - codePoints(text) - ': '.length;
52
55
  if (squashed && room > 0) {
53
- text += ': ' + (codePoints(squashed) <= room ? squashed : [...squashed].slice(0, room - 3).join('').trimEnd() + '...');
56
+ text += ': ' + (codePoints(squashed) <= room ? squashed : [...squashed].slice(0, room - 3).join('').replace(/ $/, '') + '...');
54
57
  }
55
58
  }
56
59
  return pauseMessage(until === null ? { text } : { resetsAt: until, text });
@@ -86,7 +89,7 @@ export function holdSentence(caller, work, reason, until) {
86
89
  }
87
90
 
88
91
  function text(value, name, high = 200) {
89
- if (typeof value !== 'string' || !value.trim() || value.length > high) throw new ServiceError(`${name} is a text of 1 to ${high} characters.`);
92
+ if (typeof value !== 'string' || !squash(value) || value.length > high) throw new ServiceError(`${name} is a text of 1 to ${high} characters.`);
90
93
  if (secretValue(value)) throw new ServiceError(`${name} looks like a credential; a hold names callers and work, never a secret.`);
91
94
  return squash(value);
92
95
  }
@@ -56,8 +56,9 @@ export class HubError extends Error {
56
56
  /**
57
57
  * The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
58
58
  * state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
59
- * runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
60
- * owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
59
+ * runtime's socket table hides other processes' sockets, or, on macOS, holds no row for this connection (DEC-0155
60
+ * RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's reason
61
+ * (`another-account`, or `no-row`, which only Linux and Windows give), retryable.
61
62
  */
62
63
  export function listenerRefusal(e) {
63
64
  const kind = e?.owner?.kind ?? null;
@@ -75,14 +76,18 @@ export function fabricRoot(platform = process.platform, env = process.env) {
75
76
  return path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric');
76
77
  }
77
78
 
78
- /** Whether a process with this id runs as this user (Fabric hub.ts#pidAlive): EPERM is another user's process. */
79
+ /**
80
+ * true: a process of this OS account runs under pid; false: none does. null (falsy): a process of another account does
81
+ * (EPERM), as when the id hub.json names was reused: not verified, never called running (FAA-304, the session hooks'
82
+ * answer since FAA-254). Never signals it.
83
+ */
79
84
  export function pidAlive(pid) {
80
85
  if (!Number.isInteger(pid) || pid <= 0) return false;
81
86
  try {
82
87
  process.kill(pid, 0);
83
88
  return true;
84
- } catch {
85
- return false;
89
+ } catch (e) {
90
+ return e?.code === 'EPERM' ? null : false;
86
91
  }
87
92
  }
88
93
 
@@ -108,7 +113,8 @@ function readSmall(file) {
108
113
 
109
114
  /**
110
115
  * hub.json read the way an agent must, every call. Answers {origin, mcpUrl, doorTokenFile, pid, startedAt, features} or
111
- * throws HubError (not-running, retryable; not-a-hub). Reads no token. `estate` is {record, applied}: with it, a hubKey
116
+ * throws HubError (not-running, retryable; not-a-hub, retryable when hub.json names another OS account's process, not
117
+ * verified, FAA-304). Reads no token. `estate` is {record, applied}: with it, a hubKey
112
118
  * the document names is checked by hubKeyCertified (DEC-0060); fabric-hub/0.1 names none.
113
119
  */
114
120
  export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now } = {}) {
@@ -142,7 +148,10 @@ export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now }
142
148
  sameRoot = false;
143
149
  }
144
150
  if (!sameRoot) throw notHub('names a door token outside the registry root');
145
- if (!alive(doc.pid)) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
151
+ const live = alive(doc.pid);
152
+ // FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
153
+ if (live === null) throw new HubError('not-a-hub', `Fabric's hub.json names process ${doc.pid}, which runs under another OS account: not verified.`, true);
154
+ if (!live) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
146
155
  const features = Array.isArray(doc.features) ? doc.features.filter((f) => typeof f === 'string') : [];
147
156
  if (estate && doc.hubKey && typeof doc.hubKey === 'object') {
148
157
  const verdict = hubKeyCertified(estate.record, doc.hubKey.kid, doc.hubKey.x, now ?? Date.now() / 1000, estate.applied ?? undefined);