@passioncode-ai/fabric-agent-adapter 0.80.2 → 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 (92) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +18 -5
  3. package/SECURITY.md +25 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  79. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  86. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  90. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  91. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -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.4`), 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,10 +79,10 @@ 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
- | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
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) |
86
86
  | L3 | `fabric_context` | one instance per context (FAA-125, N-11): a context's instance, folders, descriptor, lock and marker; not in the facade, installed with `--module fabric_context` ([contexts](estate-record.md#one-instance-per-context-the-helper)) |
87
87
  | L3 | `fabric_auth` | the token, the network guard, login codes |
88
88
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
@@ -92,6 +92,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
92
92
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
93
93
  | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92), a job's approval step under the mandate (`job_step`, C3.3) |
94
94
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
95
+ | L3 | `fabric_holds` | work a service holds by its own limits (FAA-298): the held job's `statusMessage` in the pause grammar (`waiting_message`, `read_waiting`, `hold_job`), one push per hold and the operator's scoped lift (`HoldBook`: `hold`, `release`, `lift`, `admit`, `limit`) ([interop](interop.md#work-held-by-the-services-own-limits)) |
95
96
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
96
97
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
97
98
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
@@ -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`.
@@ -145,27 +146,34 @@ it at boot, before any login, as the account its `UserName` names.
145
146
  install requires (without it the job would run as root, which every client refuses); the file goes in
146
147
  `/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
147
148
  ignored); `domain="system"` (Node: `domain: 'system'`) says so for any path, and an explicit domain wins over the
148
- path. Everything else is a LaunchAgent's.
149
+ path, except that an install refuses a pair launchd would contradict: `domain="system"` with a plist in a
150
+ `LaunchAgents` folder (launchd also loads that into `gui/<uid>` at login) and `domain="gui"` with a plist in
151
+ `/Library/LaunchDaemons` (launchd loads it at boot as root). A domain name given as the plist path (`"system"`,
152
+ `"gui"`) is refused, never read as a relative path (FAA-286). Everything else is a LaunchAgent's.
149
153
  `UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
150
154
  - **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
151
155
  2 above, for example with `sudo -u <user> -H`), so they live in that user's home with its modes, and the plist's
152
156
  working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
153
157
  and at uninstall `remove_descriptor` runs as the `UserName` again. A service moved from a LaunchAgent is uninstalled
154
- as one first, so launchd never holds both. The descriptor's `lifecycle.plist` names the path exactly,
158
+ as one first, so launchd never holds both: the install refuses a plist of the same label left in the `UserName`'s
159
+ `~/Library/LaunchAgents`, and the probe's `lifecycle.one-copy` fails a label loaded in both domains (FAA-286). The descriptor's `lifecycle.plist` names the path exactly,
155
160
  `/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
156
161
  - **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
157
162
  Mac, so a LaunchDaemon's label is unique on the machine, not per account (put the account in it on a Mac with a
158
163
  personal and an organization account); an install refuses, before writing anything, a label another plist holds
159
164
  (the one launchd loaded the job from, or any plist of that label in the same folder) and the plist at its path when it
160
165
  names another `UserName` or another label; an uninstall refuses a label another plist holds before its `bootout`,
161
- which would stop that plist's job, so pass it the label the plist at its path names.
166
+ which would stop that plist's job, so pass it the label the plist at its path names; in either domain an uninstall
167
+ refuses a plist at its path that names another label, before its `bootout` (FAA-286).
162
168
  - **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
163
169
  anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
164
170
  `launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
165
171
  does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
166
172
  again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
167
173
  descriptor it rewrites) as the `UserName` as an install does, and runs only the plist and `launchd_install` as root; a
168
- host without root reports it as the operator's. A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
174
+ host without root reports it as the operator's. Its commands name absolute paths: `~` would be root's home, and the
175
+ probe's `lifecycle.plist` fails a LaunchDaemon command that starts with `~`, as it fails a LaunchDaemon plist without
176
+ `UserName` (FAA-286). A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
169
177
  `bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
170
178
  `kickstart -k system/<label>`; a host or broker without root reports the job read-only.
171
179
  - **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
@@ -177,23 +185,32 @@ it at boot, before any login, as the account its `UserName` names.
177
185
  that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
178
186
  an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
179
187
  `http://127.0.0.1:<port>`, where nothing could prove it. Where the socket table is restricted it accepts a port that
180
- pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (CT-261). A `ProgramArguments` wrapper that
188
+ pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (DEC-0155 RT-3). A `ProgramArguments` wrapper that
181
189
  starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
182
190
  install refused after its bootstrap leaves the job loaded, and its plist starts it again at boot; the error says so,
183
- and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
191
+ and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. A
192
+ bootstrap that fails all five tries removes the plist the call wrote, and names one that was there before, which
193
+ would load at the next boot (FAA-286). A plist that does not parse is refused as such before anything is written,
194
+ never as a missing `UserName`; the Node installer refuses a binary plist and reads only the root dict's keys. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
184
195
  in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
185
196
  it. A descriptor field for the domain is the contract's CT-234, open.
186
197
  - **A contract exception, accepted in principle.** At the pin, HA-2 reads a launch daemon's listener as another
187
- account's and nothing it answers as the service's; the contract's later RT-3 vouches only for the caller's own uid. The
198
+ account's and nothing it answers as the service's; the pin's RT-3 (DEC-0155) vouches only for the caller's own uid. The
188
199
  root installer reads its own job's token-free well-known answer anyway, proved by launchd's pid, that pid's uid and its
189
200
  bind on exactly `127.0.0.1` (`lsof -p`, never a wildcard), and sends no credential; under a restricted socket table the
190
- first proof is the CT-261 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
201
+ first proof is the RT-3 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
191
202
  principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
192
203
  text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
193
204
  Every other client keeps HA-2 as written.
194
205
  - **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
195
206
  its plist into `gui/<uid>`. Fabric Dashboards from 0.8.39 takes the domain from the plist's folder and offers no verbs
196
- for a system job; the lifecycle broker is still asked (FAA-287).
207
+ for a system job; the lifecycle broker is still asked (FAA-287). A product with its own launchd reader (a status
208
+ page's `launchd_pid`, an installer of its own in another language) that asks only `gui/<uid>` reports a LaunchDaemon
209
+ as not loaded: move that reader to the plist's domain before the product moves to the system domain (FAA-286).
210
+ - **Off macOS the twins differ, on purpose.** Python's `launchd_domain()` answers `gui/<uid>` on Linux (it refuses only
211
+ Windows), so the kit's launchd tests run on Linux CI against a fake `launchctl`; Node's `launchdDomain()` refuses off
212
+ `darwin` unless a test passes `platform: 'darwin'`. Neither runs launchd off macOS: install with
213
+ `systemd_install` / `task_install` there (FAA-286 item 5, recorded rather than aligned).
197
214
 
198
215
  ## One copy under a supervisor
199
216
 
@@ -365,6 +382,26 @@ holds the same name beside its file lock, so the two kits exclude each other eve
365
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
366
383
  no Python copy or probe sees it, so the Node kit refuses to start there with a sentence naming Node 22 (FAA-72).
367
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
+
368
405
  **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
369
406
  token files*): the kits write a protected list: the user, SYSTEM and Administrators, set before the
370
407
  token's bytes go in, any entry already there dropped. On read, `windows_token_acl_problem` /
@@ -31,4 +31,5 @@ Order matters: each step leaves the service answering.
31
31
  | Data, logs or caches inside the repository | the OS data, log and cache directories; the old tree read-only for one release |
32
32
  | `KeepAlive {SuccessfulExit:false}`, a plist pointing at a checkout or a worktree | a plist generated by `launchd_plist`, pointing at a release |
33
33
  | A port another service already uses by default | the port claim at install; move the newcomer |
34
+ | A port held by socket activation (launchd `Sockets`, a systemd `.socket` unit) or by a root proxy in front of the service | the service binds `127.0.0.1:<port>` itself as its own account; launchd's or root's socket reads as `system` and the hosts' and the probe's `listener.owner` refuse it (DEC-0154 HA-2) |
34
35
  | An event log in a private shape | the events view over it, with one sentence per event |
@@ -42,6 +42,16 @@ python3 scripts/fabric_package.py pack <folder> # a dry run: the packa
42
42
  python3 scripts/fabric_package.py pack <folder> --apply # writes fabric.package.json when nothing is wrong
43
43
  ```
44
44
 
45
+ When the folder carries the agent's source manifest, `fabric.source.json` (contract DEC-0149, `fabric-source/1`), `pack`
46
+ writes `secrets[]` from its `secret` slots (name, purpose and `optional`) and `state[]` from its `store` slots' paths,
47
+ says so in its notes, and lists the manifest in `files[]` like any other file; `pack`, `sign` and `verify` refuse a
48
+ package whose `secrets[]` or `state[]` differ from the slots (FAC-SEM-160). A hand-written `secrets[]` naming a secret
49
+ the manifest has no slot for is never dropped: `pack` refuses (rule `source`, `/secrets`) and names each one, so add
50
+ it as a `secret` slot (or take it out) and pack again. An author's `state: []` is a copy packed
51
+ without state and is kept. A manifest that is no `fabric-source/1` object is refused. The manifest's own rules and the
52
+ tree check under it are the source check's (`references/source-and-instance.md` of adapting-projects-to-fabric, *The
53
+ source manifest*).
54
+
45
55
  Run `adapting-projects-to-fabric/scripts/fabric_source_check.py check <folder> --every-file` on the folder you pack first (a floor under your own
46
56
  review, not a secret scanner): a package carries
47
57
  the source, and the instance (the owner's paths, keys, strings) stays out of it (`references/source-and-instance.md` of
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `18f8d48762edc5bfb1433657cbfe1c16196df1a6`
3
+ Pinned to `fabric-agent-contract` commit `93e7db164c7829e2783b4b7705c4113bb5201131`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -224,21 +224,29 @@ well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at
224
224
  The kit's module is `fabric_peer_owner` / `fabric-peer-owner.mjs` (layer L0, [kit modules](kit-modules.md)): `listener(port)`,
225
225
  `server_end(sock)` / `serverEnd(socket)`, `peer`, and a connection checked before a byte is written
226
226
  (`CheckedHTTPConnection`; Node `checkedSocket`, `CheckedAgent`). Python reads macOS through `sysctl
227
- net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`), Linux through `/proc/net/tcp`
227
+ net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`; the pinned HA-2 table names
228
+ `netstat -anv` and `pcblist64`, an older record layout of the same kernel table), Linux through `/proc/net/tcp`
228
229
  and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's token SID; Node reads `netstat -anv` and `ps`
229
230
  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
230
231
  `this-account` refuses, `unknown` included.
231
232
 
232
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
233
234
  process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
234
- 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
235
241
  server itself may vouch with that pid: `lsof -p <pid>` asks the process, which the restriction leaves readable, and a
236
242
  listener of this account's pid on the port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node
237
243
  `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
238
244
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
239
- 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
240
- 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;
241
- the contract's ruling on vouching is CT-261.
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`).
242
250
 
243
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
244
252
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
@@ -248,8 +256,23 @@ a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub cl
248
256
  is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
249
257
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
250
258
  twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
251
- misread; the Python twin's `sysctl` reader takes both from one record. A service's own loopback server refusing another account's peer (HA-3) is FAA-227, and the port
252
- claim file (HA-1) waits on the contract's CT-228.
259
+ misread; the Python twin's `sysctl` reader takes both from one record. The server side (HA-2, HA-3; FAA-227): the kit's `LoopbackHTTPServer` and Node's `loopbackServer(httpServer)` tell
260
+ each accepted connection's peer (`peer`) before a byte of the request is read, in the connection's own thread or
261
+ while the socket is still paused. `peer_check` / `peerCheck` (`PEER_CHECKS`): `refuse`, the default, closes a peer of
262
+ another OS account unread and serves the rest; `strict` also refuses an owner nobody can tell (a restricted table
263
+ included) and a system account other than root (uid 0, which can read every account's files anyway and installs a
264
+ LaunchDaemon; a Windows service account is refused); `off` reads no table. The check applies to loopback peers
265
+ (127.0.0.0/8, `::1`, either IPv4-mapped): HA-2 is about another account of this computer, so a peer at any other
266
+ address (a service bound to `0.0.0.0`) is served under `refuse` and `strict` alike without a table read, and its
267
+ owner is none (`peer_owner()` None, `connectionOwner` null); such a peer is never T0 or T1. Use `strict` for a dashboard, an MCP
268
+ surface or a token-free door on a computer with several counted accounts, where the interpreter sees the whole
269
+ table (Apple's `/usr/bin/python3`, not an ad-hoc-signed one on macOS 27.2). A refusal writes one line on stderr
270
+ (kind, uid, pid, never the peer's bytes); a handler finds its peer's owner with `self.server.peer_owner()` or
271
+ `connectionOwner(req.socket)`. The cost is one table read per connection, not per request: a few milliseconds from
272
+ `sysctl` or `/proc` in-process; Node on macOS and Windows runs `netstat` and `ps` or PowerShell for each, tens to
273
+ hundreds of milliseconds, so a Node service there that answers the well-known read within 100 ms keeps its
274
+ connections alive or chooses `off` where one account uses the computer. The port claim file (HA-1) waits on the
275
+ contract's CT-228.
253
276
 
254
277
  ## Semantic rules a host applies
255
278
 
@@ -127,6 +127,25 @@ async function serviceTarget(spec, dir) {
127
127
  return { url, declared, name: `${id}.${instance}` };
128
128
  }
129
129
 
130
+ // DEC-0154 PL-3 and HA-2 (FAA-260 items 2 and 14): a raw loopback address opens in the browser only while its port's
131
+ // listener is this OS account's, read from the socket table just before the page opens. The browser connects on its
132
+ // own, so this is the listener's owner, not that connection's: a listener swapped in between stays a residue
133
+ // (SECURITY.md). A copy of this file alone cannot tell the owner and opens no loopback page.
134
+ async function loopbackRefusal(target) {
135
+ let url;
136
+ try { url = new URL(target); } catch { return null; }
137
+ if (url.protocol !== 'http:') return null;
138
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
139
+ if (!(host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1'
140
+ || host === '0.0.0.0' || /^::ffff:(127\.|7f)/.test(host))) return null;
141
+ const po = await import('./fabric-peer-owner.mjs').catch(() => null);
142
+ if (!po) return 'fabric-peer-owner.mjs is not beside this file: the loopback listener\'s owner cannot be told, so the page is not opened (DEC-0154 HA-2)';
143
+ const port = Number(url.port || 80);
144
+ const who = await po.listener(port);
145
+ if (who.kind === po.THIS_ACCOUNT) return null;
146
+ return `foreign-user: the listener on port ${port} reads as ${who.kind}${who.reason ? ` (${who.reason})` : ''}, so the page is not opened (DEC-0154 PL-3, HA-2)`;
147
+ }
148
+
130
149
  // Runs inside the page. Everything it reports is measured from the rendered layout.
131
150
  function measure([floor, cap]) {
132
151
  const de = document.documentElement;
@@ -319,6 +338,8 @@ async function main(argv) {
319
338
  try {
320
339
  const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
321
340
  const page = await context.newPage();
341
+ const refused = await loopbackRefusal(target);
342
+ if (refused) return notRun(refused);
322
343
  const response = await page.goto(target, { waitUntil: 'load' });
323
344
  if (response && !response.ok()) {
324
345
  return notRun(`the page answered HTTP ${response.status()}`);
@@ -28,8 +28,32 @@ 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
- SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
- r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
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
+
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
50
+ # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
51
+ # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
52
+ SECRET_VALUE = re.compile(r'(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"\']|\\u[0-9A-Fa-f]{4}'
53
+ r'|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m'
54
+ r'|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)'
55
+ r'(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})'
56
+ r'|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,}))', re.S)
33
57
  LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
58
 
35
59
 
@@ -52,7 +76,7 @@ def credential_name(name):
52
76
  def secret_value(value):
53
77
  long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
54
78
  for run in LONG_RUN.findall(value))
55
- 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)
56
80
 
57
81
 
58
82
  def carries_credential(text):
@@ -115,6 +139,11 @@ def check(data):
115
139
  require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
116
140
  fallback = data.get('fallback', 'if_absent')
117
141
  require(fallback in ('if_absent', 'never'), 'invalid_fallback')
142
+ # DEC-0154 PL-3 and HA-2 (FAA-251): who listens on http_url's port, as the host read it from the socket table
143
+ # (fabric_peer_owner's kinds, or check_service.py's foreign-user). A browser opens a raw loopback address only
144
+ # after that listener is this OS account's; absent, it is unknown.
145
+ owner = data.get('listener_owner', 'unknown')
146
+ require(owner in ('this-account', 'another-account', 'system', 'unknown', 'none', 'foreign-user'), 'invalid_listener_owner')
118
147
  kind = action.get('kind')
119
148
  if kind == 'remote_open':
120
149
  require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
@@ -128,6 +157,7 @@ def check(data):
128
157
  require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
129
158
  require(viewer == target, 'wrong_viewer_device')
130
159
  require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
160
+ require(owner == 'this-account', 'listener_not_this_account')
131
161
  else:
132
162
  raise ValueError('unsupported_dashboard_action')
133
163
  return {'ok': True, 'kind': kind}
@@ -80,7 +80,7 @@ import fabric_connections as fcn # noqa: E402
80
80
  from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
81
81
  import fabric_interop as fi # noqa: E402
82
82
  import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2: the listener's owner before any credential
83
- from check_service_rules import ForeignUser, PORT_RULES_UNREAD, listener_owner_verdict # noqa: E402 - DEC-0154 HA-2
83
+ from check_service_rules import ForeignUser, PORT_RULES_UNREAD, in_launch_daemons, listener_owner_verdict, other_domain_copy, port_claim_verdict # noqa: E402 - DEC-0154 HA-2
84
84
 
85
85
  Result = Dict[str, str]
86
86
 
@@ -97,7 +97,7 @@ from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-6
97
97
  from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
98
98
  from check_service_rules import merge_headers, session_cookie # noqa: E402,F401 - FAA-115
99
99
  from check_service_rules import well_known_actions_verdict, action_tools_verdict, actions_declared_verdict # noqa: E402 - DEC-0121
100
- from check_service_rules import opens_in_host_verdict, service_checkout # noqa: E402 - FAA-118
100
+ from check_service_rules import opens_in_host_verdict, service_checkout, declared_limits, holds_verdict # noqa: E402 - FAA-118, FAA-298
101
101
  from check_service_rules import well_known_a2a_verdict, a2a_card_verdict # noqa: E402 - DEC-0122
102
102
  import fabric_a2a as fa2 # noqa: E402 - DEC-0122
103
103
  import check_service_channel_agent as cac # noqa: E402 - DEC-0150, FAA-197
@@ -153,6 +153,7 @@ class Probe:
153
153
 
154
154
  def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
155
155
  body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
156
+ """`connected_at`: when a local connect and its owner check ended, so the answer is timed from there (r11)."""
156
157
  if self.remote:
157
158
  context = ssl.create_default_context(cafile=self.ca_file) if self.ca_file else ssl.create_default_context()
158
159
  if self.connect:
@@ -167,6 +168,9 @@ class Probe:
167
168
  base = {"Host": "127.0.0.1:%d" % self.port}
168
169
  base.update(headers or {})
169
170
  try:
171
+ if not self.remote:
172
+ conn.connect()
173
+ self.connected_at = time.perf_counter()
170
174
  conn.request(method, path, body=body, headers=base)
171
175
  resp = conn.getresponse()
172
176
  return resp.status, merge_headers(resp.getheaders()), resp.read()
@@ -203,25 +207,7 @@ class Probe:
203
207
  else:
204
208
  mode = stat.S_IMODE(os.stat(self.path).st_mode)
205
209
  self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
206
- me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
207
- clashes = []
208
- for path, other in fs.read_descriptors(self.dir):
209
- key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
210
- if path.resolve() == self.path.resolve():
211
- continue
212
- if key == me:
213
- clashes.append("%s declared again in %s" % (me, path.name))
214
- # DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
215
- elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
216
- and other.get("origin") == self.d.get("origin")):
217
- clashes.append("port %d also claimed by %s" % (self.port, key))
218
- if self.remote:
219
- unique = "a remote origin claims no port here"
220
- elif "origin" not in self.d:
221
- unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
222
- else:
223
- unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % self.port
224
- self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
210
+ self.add("descriptor.port-claim", *port_claim_verdict(self.path, self.d, self.dir, self.port, self.remote))
225
211
  # FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
226
212
  # is named here (NOT_RUN is no pass) until its owner adds it.
227
213
  # A source descriptor.valid refuses is no pass either, and the evidence never carries a URL's userinfo or query,
@@ -260,9 +246,9 @@ class Probe:
260
246
  try:
261
247
  timings = []
262
248
  for _ in range(3):
263
- started = time.perf_counter()
249
+ started = self.connected_at = time.perf_counter()
264
250
  status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
265
- timings.append((time.perf_counter() - started) * 1000)
251
+ timings.append((time.perf_counter() - max(started, self.connected_at)) * 1000)
266
252
  except OSError as exc:
267
253
  if self.remote:
268
254
  self.add_no_token(no_token, None)
@@ -276,7 +262,7 @@ class Probe:
276
262
  try:
277
263
  self.wk = json.loads(body)
278
264
  self.wk_bytes = len(body) # D4 of the 0.73.0 brief: a well-known answer shares a 2 MiB cap
279
- except ValueError:
265
+ except (ValueError, RecursionError): # Python 3.9 to 3.13 raise RecursionError on deep nesting (unit reading 10)
280
266
  if self.remote:
281
267
  self.add_no_token(no_token, None)
282
268
  self.add("well-known.answers", "FAIL", "body is not JSON")
@@ -467,7 +453,7 @@ class Probe:
467
453
  self.add("usage.report", "FAIL", "HTTP %d with the token" % status)
468
454
  return
469
455
  try:
470
- report = json.loads(body)
456
+ report = self.usage_report = json.loads(body) # FAA-298: limits.holds reads its budgets
471
457
  except ValueError:
472
458
  self.add("usage.report", "FAIL", "not JSON")
473
459
  return
@@ -823,7 +809,7 @@ class Probe:
823
809
  except (OSError, ValueError) as exc:
824
810
  self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
825
811
  return
826
- problems = plist_problems(plist, life.get("label"), self.token)
812
+ problems = plist_problems(plist, life.get("label"), self.token, system=in_launch_daemons(plist_path), commands=self.d.get("commands"))
827
813
  self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
828
814
  marker = marker_problem(plist)
829
815
  self.add("lifecycle.supervisor-marker", "FAIL" if marker else "PASS",
@@ -839,10 +825,15 @@ class Probe:
839
825
  out = subprocess.run(["launchctl", "print", "%s/%s" % (domain, life.get("label"))], capture_output=True, text=True)
840
826
  match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
841
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
842
830
  if out.returncode != 0:
843
831
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
844
832
  elif not match:
845
833
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
834
+ elif second: # loaded in both domains: two copies (FAA-286 item 6)
835
+ self.add("lifecycle.one-copy", "FAIL", "launchd job %s is loaded in %s and in %s: two copies; uninstall one"
836
+ % (life.get("label"), domain, second))
846
837
  else:
847
838
  same = int(match.group(1)) == served
848
839
  self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
@@ -947,8 +938,8 @@ class Probe:
947
938
  self.login_rules()
948
939
  self.interop_rules()
949
940
  self.channel_agent_rule()
950
- verdict, evidence = board_responder_verdict(self.wk, self.board_kit)
951
- self.add("board.responder", verdict, evidence)
941
+ self.add("limits.holds", *holds_verdict(declared_limits(self.wk, getattr(self, "usage_report", None)), *service_checkout(self.d, self.repo)))
942
+ self.add("board.responder", *board_responder_verdict(self.wk, self.board_kit))
952
943
  self.lifecycle_rules()
953
944
  return self.results
954
945
 
@@ -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):
@@ -457,7 +459,8 @@ def secrets(value: Any, root: str, budget: Budget) -> List[Dict[str, str]]:
457
459
  def flag(at: str, what: str, text: str, ident: bool) -> None:
458
460
  if budget.named < SECRET_FINDINGS:
459
461
  budget.named += 1
460
- rename = "; an id that starts with sk- is read as a key; rename it" if ident and re.match(r"sk-", text, re.I | re.A) else ""
462
+ # canonical case only (DEC-0151): an upper-case SK- is no key, so the advice would name the wrong cause
463
+ rename = "; an id that starts with sk- is read as a key; rename it" if ident and text.startswith("sk-") else ""
461
464
  findings.append(_finding("FAC-SEM-165", at, "%s is shaped like a credential: a token is named by its vault slot, never written%s (DEC-0150 CA-5)" % (what, rename)))
462
465
  else:
463
466
  budget.more += 1