@passioncode-ai/fabric-agent-adapter 0.76.0 → 0.78.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 (49) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +22 -6
  3. package/SECURITY.md +25 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +55 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +33 -44
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +55 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +4 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +325 -24
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +9 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +44 -15
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +310 -23
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +209 -9
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +15 -4
  33. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +55 -0
  34. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +2 -2
  35. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +5 -2
  36. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +2 -1
  37. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  38. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  39. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  40. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  41. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  42. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  43. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -36
  44. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +55 -0
  45. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  46. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -1
  47. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  49. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -37,7 +37,7 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
37
37
  | The body is read to one byte past `max_bytes`; over it is an error | 8 MiB | a runaway answer never fills memory |
38
38
  | Redirects are answered, never followed | | a redirect must not carry the `Authorization` header to another host |
39
39
  | Plain `http://` is refused unless the host is loopback or `insecure=True`; credentials in a URL are refused | | a token never crosses a network in clear, nor sits in a URL |
40
- | Plain `http://` to a loopback host goes on a connection whose server end is this OS account's process, told from the socket table before a byte is sent and never through a proxy from the environment (`fabric_peer_owner`); another account's listener, root's, or one whose owner cannot be told is kind `foreign-user`, nothing sent, never retried. The Node twin sends such a request through `node:http` on a checked connection, not `fetch` | | DEC-0154 HA-2: loopback is one per computer, not one per OS account |
40
+ | Plain `http://` to a loopback host goes on a connection whose server end is this OS account's process, told from the socket table before a byte is sent and never through a proxy from the environment (`fabric_peer_owner`); another account's listener, root's, or one whose owner cannot be told is kind `foreign-user`, nothing sent, never retried (on a restricted macOS table its reason says `table-restricted`: protocol.md, FAA-272). The Node twin sends such a request through `node:http` on a checked connection, not `fetch` | | DEC-0154 HA-2: loopback is one per computer, not one per OS account |
41
41
  | Header names must be tokens; a value with a line break or NUL is refused | | header injection |
42
42
  | A JSON body is written as `JSON.stringify` writes it: a lone surrogate escaped, NaN and Infinity refused | | both twins send the same bytes |
43
43
  | Whatever the transport throws is a connection error, in both languages; a timeout is a timeout | | |
@@ -25,7 +25,10 @@ in the URL.
25
25
  - `kind`: dotted, stable, past tense or state (`job.failed`, `job.awaiting_choice`,
26
26
  `service.started`, `collector.stale`). Hosts filter on it.
27
27
  - `level`: `info` (routine), `notice` (the operator may want to look), `warning`
28
- (something is degrading), `error` (something failed and needs action).
28
+ (something is degrading), `error` (something failed and needs action). The kits take `warn`, the
29
+ dashboard chip's name, and store it as `warning` (FAA-276), never as `error`: a failed action is `error` (the chip
30
+ `danger`). The alias goes by name only: an event that needs a person stays `notice` with notify (below), whatever
31
+ chip a page shows for it.
29
32
  - `text`: one sentence, in the operator's language, with the object named and the
30
33
  number stated: "The Q3 report draft is ready for your approval (12 sections, 3
31
34
  languages)." Never a machine id, never a stack trace.
@@ -59,7 +59,8 @@ 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.2`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
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.3`), 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
+ | 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)) |
63
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
64
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
65
66
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
@@ -85,7 +86,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
85
86
  | L3 | `fabric_auth` | the token, the network guard, login codes |
86
87
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
87
88
  | L3 | `fabric_logs` | rotating logs, the stop signal and the drain (LC-12) |
88
- | L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
89
+ | L3 | `fabric_supervisors` | launchd (a LaunchAgent, or a LaunchDaemon in the system domain, FAA-278), systemd user units, Task Scheduler (DEC-0032, LC-14) |
89
90
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
90
91
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
91
92
  | 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) |
@@ -13,6 +13,7 @@ PassionCode.ai the same rules make up the organization's product lifecycle contr
13
13
  - [The rules](#the-rules)
14
14
  - [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes)
15
15
  - [Install, upgrade, uninstall](#install-upgrade-uninstall)
16
+ - [The system domain](#the-system-domain)
16
17
  - [One copy under a supervisor](#one-copy-under-a-supervisor)
17
18
  - [An app's own descriptor and the node](#an-apps-own-descriptor-and-the-node)
18
19
  - [Directories](#directories)
@@ -49,7 +50,8 @@ carry a credential. Only `doctor` and `update` are commands a host may run.
49
50
 
50
51
  **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
51
52
  authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's,
52
- on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers).
53
+ on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers; a
54
+ LaunchDaemon's root installer reads it as *The system domain* says).
53
55
  `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
54
56
  full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
55
57
  that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
@@ -76,7 +78,7 @@ systemd and Task Scheduler do not poll yet, FAA-253).
76
78
  |---|---|---|
77
79
  | `Label` | reverse-DNS, stable (`com.example.example-agent`) | the host controls the job by label |
78
80
  | `ProgramArguments` | absolute interpreter + module/script inside a **release** directory | a worktree or checkout path breaks the day it moves |
79
- | `RunAtLoad` | `true` | back at every login |
81
+ | `RunAtLoad` | `true` | back at every login (a LaunchDaemon: at every boot) |
80
82
  | `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
81
83
  | `ThrottleInterval` | `10` | bounds a crash loop |
82
84
  | `ExitTimeOut` | above the drain and its hard exit (15 s default; `Drain` ends by 10 s) | SIGKILL arrives after it, so it must never come first |
@@ -102,11 +104,13 @@ systemd and Task Scheduler do not poll yet, FAA-253).
102
104
  cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
103
105
  `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
104
106
  vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
107
+ A LaunchDaemon reads its answer differently: *The system domain*, below.
105
108
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
106
109
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
107
110
  and the one before it stay for rollback, older ones go (LC-11, LC-15).
108
111
  5. Uninstall = `launchd_uninstall` (Node: `await launchdUninstall(...)`) + `remove_descriptor`. Data stays; `purge=True`
109
- (with `service_id`) removes data, logs and cache when the operator asks.
112
+ (with `service_id`) removes data, logs and cache when the operator asks; a LaunchDaemon refuses `purge` (*The system
113
+ domain*, below).
110
114
 
111
115
  **The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
112
116
  `disable` (off survives a login) and starts it with `enable` + `bootstrap`; restart is
@@ -123,6 +127,68 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
123
127
  off. Tests use a throwaway label or a fake `launchctl` first on `PATH`, never the
124
128
  operator's real jobs.
125
129
 
130
+ ## The system domain
131
+
132
+ A `gui/<uid>` job (a LaunchAgent) runs only while its user is logged in. A headless Mac that a FileVault restart
133
+ (`fdesetup authrestart`) leaves with no GUI login, or an SSH session of a user with no GUI login, has none: `bootstrap`
134
+ answers error 125 ("Domain does not support specified action") and the installer's error says to remove the LaunchAgent
135
+ it wrote (`launchd_uninstall`) and use the system domain (FAA-278). There the job is a **LaunchDaemon**: launchd starts
136
+ it at boot, before any login, as the account its `UserName` names.
137
+
138
+ - **The plist**: `launchd_plist(..., user_name="<short name>")` (Node: `userName`) writes `UserName`, which a system
139
+ install requires (without it the job would run as root, which every client refuses); the file goes in
140
+ `/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
141
+ ignored); `domain="system"` (Node: `domain: 'system'`) says so for any path, and an explicit domain wins over the
142
+ path. Everything else is a LaunchAgent's.
143
+ `UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
144
+ - **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
145
+ 2 above, for example with `sudo -u <user> -H`), so they live in that user's home with its modes, and the plist's
146
+ working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
147
+ and at uninstall `remove_descriptor` runs as the `UserName` again. A service moved from a LaunchAgent is uninstalled
148
+ as one first, so launchd never holds both. The descriptor's `lifecycle.plist` names the path exactly,
149
+ `/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
150
+ - **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
151
+ Mac, so a LaunchDaemon's label is unique on the machine, not per account (put the account in it on a Mac with a
152
+ personal and an organization account); an install refuses, before writing anything, a label another plist holds
153
+ (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
154
+ names another `UserName` or another label; an uninstall refuses a label another plist holds before its `bootout`,
155
+ which would stop that plist's job, so pass it the label the plist at its path names.
156
+ - **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
157
+ anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
158
+ `launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
159
+ does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
160
+ again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
161
+ descriptor it rewrites) as the `UserName` as an install does, and runs only the plist and `launchd_install` as root; a
162
+ 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
163
+ `bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
164
+ `kickstart -k system/<label>`; a host or broker without root reports the job read-only.
165
+ - **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
166
+ `launchctl print system/<label>`, and `launchd_override(label, "system")` asks `launchctl print-disabled system`
167
+ (Node: the `domain: 'system'` option), both of which any account may read; the probe's `lifecycle.one-copy` reads the
168
+ domain from the descriptor's plist the same way.
169
+ - **The answer the installer waits for**: root reads the service's listener as another account's, so the installer takes
170
+ the job's pid from `launchctl print system/<label>` and reads the token-free well-known document only on a connection
171
+ that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
172
+ an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
173
+ `http://127.0.0.1:<port>`, where nothing could prove it. Where the socket table is restricted it accepts a port that
174
+ pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (CT-261). A `ProgramArguments` wrapper that
175
+ starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
176
+ install refused after its bootstrap leaves the job loaded, and its plist starts it again at boot; the error says so,
177
+ 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
178
+ in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
179
+ it. A descriptor field for the domain is the contract's CT-234, open.
180
+ - **A contract exception, accepted in principle.** At the pin, HA-2 reads a launch daemon's listener as another
181
+ 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
182
+ root installer reads its own job's token-free well-known answer anyway, proved by launchd's pid, that pid's uid and its
183
+ bind on exactly `127.0.0.1` (`lsof -p`, never a wildcard), and sends no credential; under a restricted socket table the
184
+ 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
185
+ principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
186
+ text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
187
+ Every other client keeps HA-2 as written.
188
+ - **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
189
+ its plist into `gui/<uid>`. Fabric Dashboards from 0.8.39 takes the domain from the plist's folder and offers no verbs
190
+ for a system job; the lifecycle broker is still asked (FAA-287).
191
+
126
192
  ## One copy under a supervisor
127
193
 
128
194
  Started by hand, a copy that finds `service.lock` held prints one sentence naming the
@@ -212,7 +212,9 @@ Loopback is one per computer, not one per OS account. Before a client sends any
212
212
  `http://127.0.0.1:<port>` (a descriptor's token, a delegation token, a login code's request, a session cookie, a door's
213
213
  token), and before a host calls a local service healthy, it tells the owner of the server end of its own connection
214
214
  from the computer's socket table. A listener of another OS account, of root, SYSTEM or a service account, or one whose
215
- owner cannot be told, is `foreign-user`: nothing is sent and nothing it answers is read as the service's. The order of
215
+ owner cannot be told, is `foreign-user`: nothing is sent and nothing it answers is read as the service's. One exception:
216
+ a LaunchDaemon's root installer reads its own job's token-free well-known answer, proved by the job's pid, uid and exact
217
+ `127.0.0.1` bind (lifecycle.md, *The system domain*; DEC-0161 accepted in principle, proposed; FAA-288). The order of
216
218
  a local service's states is `down`, `foreign-user`, `foreign` (`FAC-SEM-009`), then the well-known `status`; the
217
219
  well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at the pin).
218
220
 
@@ -232,14 +234,14 @@ listener of this account's pid on the port counts as this account's (`CheckedHTT
232
234
  `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
233
235
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
234
236
  whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
235
- IPv4 wildcard), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
237
+ 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;
236
238
  the contract's ruling on vouching is CT-261.
237
239
 
238
240
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
239
241
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
240
242
  `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
241
243
  `check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
242
- is never a successful install; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
244
+ 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
243
245
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
244
246
  twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
245
247
  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
@@ -79,6 +79,11 @@ watch the gate fail.
79
79
  "used before honoured" write); memory works for one process.
80
80
  - **Behind a router**: check the platform-set forwarded scheme; `Host` is preserved by common
81
81
  platforms and must equal the origin's host.
82
+ - **A provider key** the remote service needs is issued and kept in Project Observatory, pushed to the platform, and
83
+ the movement recorded with
84
+ `python3 "$(project-observatory full-path)/tools/vault.py" moved <project> <env> <NAME> --how "<what moved, where, evidence>" --at <provider>`,
85
+ so the platform's copy is known rather than invisible. Without Project Observatory, record where the key lives in
86
+ the person's own secret manager.
82
87
 
83
88
  ## Checking it
84
89
 
@@ -5,6 +5,7 @@
5
5
  - [MCP for agents](#mcp-for-agents)
6
6
  - [CLI for people and scripts](#cli-for-people-and-scripts)
7
7
  - [A2A for remote agents](#a2a-for-remote-agents)
8
+ - [Behind tailscale serve](#behind-tailscale-serve)
8
9
  - [Token](#token)
9
10
  - [Dashboard session](#dashboard-session)
10
11
  - [Headers every response carries](#headers-every-response-carries)
@@ -40,7 +41,9 @@
40
41
 
41
42
  ## CLI for people and scripts
42
43
 
43
- `<tool> service status|start|stop|restart` (launchctl on the label, then the
44
+ `<tool> service status|start|stop|restart` (launchctl on the label in the domain `launchd_domain(plist)` names; for a
45
+ LaunchDaemon start, stop and restart are root's, and a CLI without root reports the job read-only, lifecycle.md, The system
46
+ domain; then the
44
47
  well-known document from this OS account's listener, else `foreign-user`: protocol.md, The listener's owner), `<tool> dashboard` (resolve the exact registered service with the `fabric-dashboards`
45
48
  MCP, run `check_dashboard_link.py` on the action, then open its `open_link` with the host's `open` and
46
49
  `fallback=never`; never `open <url>`, `open -a Safari`, `xdg-open` or `webbrowser.open`; an installed host that
@@ -82,6 +85,45 @@ A probe cannot see whether an interface would take the descriptor's token withou
82
85
  forbids, so it reads the card only and never calls an interface: an API-key scheme in the descriptor's own
83
86
  `auth.header` is a finding, and with `Authorization` (the default) the card cannot show it.
84
87
 
88
+ ## Behind tailscale serve
89
+
90
+ An agent on a server (a VM, a teammate's machine) can reach people on the tailnet through `tailscale serve`, which
91
+ proxies HTTPS on the node's tailnet name to a loopback listener and adds the caller's `Tailscale-User-Login`. The
92
+ kit's `fabric_tailnet.py` / `fabric-tailnet.mjs` (FAA-297) turn that into a decision; the agent wires it into its own
93
+ handler.
94
+
95
+ - **A listener of serve's own.** Any process on the machine can send `Tailscale-User-Login` to a loopback port, so the
96
+ header is believed only on a listener that serve alone uses: a second loopback port, or, where the installed
97
+ `tailscale serve` can proxy to one, a Unix socket whose mode keeps other accounts out. Pass `listener="serve"` and the
98
+ connection's peer (`"unix"` for a socket). On any other listener a request carrying serve's headers is refused
99
+ (`serve-header-off-serve`); the Fabric listener keeps its session key and never reads them.
100
+ - **A perimeter generated, not kept by hand.** `load_perimeter` / `loadPerimeter` take a `tailnet-perimeter/1`
101
+ document: `generatedFrom {registry, revision, at}` (required: a file without its source is refused,
102
+ `perimeter-not-generated`) and `groups {name: {members, dashboards, capabilities}}`. The organization's generator
103
+ writes it from its access registry; the agent reloads it when the file changes.
104
+ - **A personal key for MCP.** `load_keys` / `loadKeys` take `[{id, login, sha256, capabilities}]`, the key's
105
+ `hash_key` / `hashKey` only. A call on the `mcp` surface needs `Authorization: Bearer <key>`; the key must belong to
106
+ the caller's login and its capabilities may not be wider than that login's groups.
107
+ - **The answer.** `authorize(listener=, peer=, headers=, perimeter=, surface="dashboard"|"mcp", target=, keys=)`
108
+ answers `{allowed, status, code, login, groups}` (and `key`, the key's id, on an allowed MCP call). Send the
109
+ status and the code as they are; no answer carries a key or its hash.
110
+
111
+ | Code | Status | When |
112
+ |---|---|---|
113
+ | `serve-header-off-serve` | 403 | serve's headers on a listener that is not serve's |
114
+ | `peer-not-loopback` | 403 | the serve listener was reached from another address |
115
+ | `identity-missing`, `identity-ambiguous`, `identity-malformed` | 401 | no login, two, or not a login |
116
+ | `not-in-perimeter` | 403 | the login is in no group |
117
+ | `dashboard-not-granted` | 403 | no group of the login lists the dashboard |
118
+ | `key-required`, `key-unknown` | 401 | an MCP call without a bearer key, or with one not on the list |
119
+ | `key-not-yours` | 403 | the key belongs to another login |
120
+ | `key-wider-than-group` | 403 | the key's capabilities exceed the login's groups |
121
+ | `capability-not-granted` | 403 | the key does not carry the capability called |
122
+
123
+ A tagged node (a server, not a person) gets no identity header from serve and is refused `identity-missing`; a
124
+ machine caller uses the estate's delegation tokens instead. The serve listener is an outward surface: watch it like
125
+ the A2A tunnel above.
126
+
85
127
  ## Token
86
128
 
87
129
  - Created once with `ensure_token` at first start, after the instance lock.
@@ -0,0 +1,5 @@
1
+ # When to use building-fabric-services
2
+
3
+ The skill's full trigger description, kept here so the front matter's `description` stays within the client's skill listing budget (about 350 characters a skill). Read it when choosing between this skill and a neighbour.
4
+
5
+ Use when running an agent as a Fabric service (fabric-service/0.1, its kits and probe): a local service (launchd, systemd, Windows), an ONLINE agent or dashboard on https, its dashboard link and kit (narrow panel, themes), data and cache paths, how callers reach and authenticate it, long jobs (fabric.job.*), spend report and limits, runner routes and model profiles, the estate record and delegation tokens, answering a Project's board requests, or packing and signing it. «сделай агенту дашборд», «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать на запросы с доски проекта», «расходы агента на модели», "make this agent a local service", "make an online dashboard a Fabric service", "add the well-known endpoint". NOT for a one-off script or cron job, a hosted product with no agent, the provider manifest (adapting-projects-to-fabric), a new agent's design (creating-fabric-agents), a session using Fabric or its board (working-with-fabric), or building the Dashboards app (window, tray).
@@ -833,11 +833,12 @@ class Probe:
833
833
  if sys.platform != "darwin" or not shutil.which("launchctl"):
834
834
  self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
835
835
  return
836
- out = subprocess.run(["launchctl", "print", "gui/%d/%s" % (os.getuid(), life.get("label"))], capture_output=True, text=True)
836
+ domain = fs.launchd_domain(plist_path) # a plist in /Library/LaunchDaemons is a LaunchDaemon (FAA-278)
837
+ out = subprocess.run(["launchctl", "print", "%s/%s" % (domain, life.get("label"))], capture_output=True, text=True)
837
838
  match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
838
839
  served = ((self.wk or {}).get("process") or {}).get("pid")
839
840
  if out.returncode != 0:
840
- self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded" % life.get("label"))
841
+ self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
841
842
  elif not match:
842
843
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
843
844
  else:
@@ -5,6 +5,9 @@ import { feedDataProblems } from './fabric-protocols.mjs';
5
5
 
6
6
  export const LEVELS = ['info', 'notice', 'warning', 'error'];
7
7
 
8
+ // The dashboard kit names its chip `warn`; an event written at that name is stored at the feed's `warning` (FAA-276).
9
+ export const LEVEL_ALIASES = Object.freeze({ warn: 'warning' });
10
+
8
11
  export const EVENTS_DEFAULT_LIMIT = 50;
9
12
 
10
13
  export const EVENTS_MAX_LIMIT = 200;
@@ -15,9 +18,10 @@ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
15
18
 
16
19
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
17
20
 
18
- /** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
19
- export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
20
- if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
21
+ /** One activity event. `level` is one of LEVELS; `warn`, the dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
22
+ export function makeEvent(id, at, kind, given, text, { subject, link, notify, traceId, spanId, data } = {}) {
23
+ const level = typeof given === 'string' && Object.hasOwn(LEVEL_ALIASES, given) ? LEVEL_ALIASES[given] : given;
24
+ if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')} (or \`warn\` for \`warning\`).`);
21
25
  if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
22
26
  const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
23
27
  if (!sentence) throw new ServiceError('An event needs a sentence.');
@@ -45,7 +45,7 @@ import http from 'node:http';
45
45
  import net from 'node:net';
46
46
  import os from 'node:os';
47
47
 
48
- export const PEER_OWNER_VERSION = '1.1.0+kit.2'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
48
+ export const PEER_OWNER_VERSION = '1.1.0+kit.3'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
49
49
  export const THIS_ACCOUNT = 'this-account';
50
50
  export const ANOTHER_ACCOUNT = 'another-account';
51
51
  export const SYSTEM = 'system';
@@ -148,6 +148,7 @@ export {
148
148
  cookieValue,
149
149
  } from './fabric-auth.mjs';
150
150
  export {
151
+ LEVEL_ALIASES,
151
152
  LEVELS,
152
153
  EVENTS_DEFAULT_LIMIT,
153
154
  EVENTS_MAX_LIMIT,
@@ -176,6 +177,10 @@ export {
176
177
  taskInstall,
177
178
  taskUninstall,
178
179
  launchdPlist,
180
+ SYSTEM_DAEMONS,
181
+ launchdDomain,
182
+ jobWellKnownOf,
183
+ ForeignUserAnswer,
179
184
  launchdLoaded,
180
185
  launchdPid,
181
186
  launchdOverride,