@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.
- package/CHANGELOG.md +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -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
|
|
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) |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
252
|
-
|
|
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()}`);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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(
|
|
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}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
951
|
-
self.add("board.responder",
|
|
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,
|
|
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.
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
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
|
-
|
|
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
|