@passioncode-ai/fabric-agent-adapter 0.77.0 → 0.79.1

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 (74) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +31 -11
  3. package/SECURITY.md +17 -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/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +15 -5
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +27 -39
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +74 -3
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +339 -26
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +367 -27
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +323 -25
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -6
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
  62. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
  63. package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
  64. package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
  65. package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
  66. package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
  67. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +30 -39
  68. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
  69. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
@@ -61,8 +61,12 @@ _URL = re.compile(URL_PATTERN)
61
61
  _MANIFEST = re.compile(MANIFEST_PATTERN[:-1] + r"\Z")
62
62
  # The manifest's name after a backslash: valid since DEC-0145, but no writer emits it until every reader accepts it.
63
63
  _MANIFEST_AFTER_BACKSLASH = re.compile(r"\\fabric-agent\.json\Z")
64
- # format: uri (RFC 3986 absolute URI): a scheme, a colon, then no whitespace or control character.
65
- _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s\x00-\x1f\x7f]+\Z")
64
+ # format: uri (RFC 3986 absolute URI) as ajv-formats reads it: RFC 3986 characters only (unreserved, reserved and %HH),
65
+ # the class the service kit's descriptor check uses (fabric_descriptor `_SOURCE_URI`, 0.49.3); a byte-order mark, a
66
+ # zero-width space, a non-ASCII path or an IDN host is refused (FAA-127).
67
+ _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:(?:[A-Za-z0-9._~:/?#@!$&'()*+,;=-]|%[0-9A-Fa-f]{2})+\Z")
68
+ # Brackets belong to an IP-literal host only (RFC 3986 3.2.2); the literal is set aside before the character check.
69
+ _IP_LITERAL = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@\[\]]*@)?)\[[0-9A-Fa-f:.vV]+\]")
66
70
  # format: date-time (RFC 3339).
67
71
  _TIMESTAMP = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})\Z")
68
72
  FIELDS = {"protocol", "id", "providerId", "name", "summary", "manifest", "run", "source", "installedAt", "installedBy", "extensions"}
@@ -145,11 +149,14 @@ def validate_provider_entry(entry: Any) -> List[str]:
145
149
  source = entry["source"]
146
150
  if not isinstance(source, dict) or not set(source) <= {"repository"}:
147
151
  problems.append("source is {repository?}")
152
+ elif "repository" in source and isinstance(source["repository"], str) and (
153
+ _URI_PASSWORD.match(source["repository"]) or _secrets.repository_credential(source["repository"])):
154
+ # FAA-120, as the service kit: a repository is named, never reached with a credential in its URI. Said
155
+ # before the character check, so a password beside a character the URI class refuses (a backslash, as
156
+ # git reads one) is still named a credential (FAA-127).
157
+ problems.append("source.repository carries no password or credential: it names the repository")
148
158
  elif "repository" in source and not _is_uri(source["repository"]):
149
159
  problems.append("source.repository is an absolute URI")
150
- elif "repository" in source and (_URI_PASSWORD.match(source["repository"]) or _secrets.repository_credential(source["repository"])):
151
- # FAA-120, as the service kit: a repository is named, never reached with a credential in its URI.
152
- problems.append("source.repository carries no password or credential: it names the repository")
153
160
  if not isinstance(entry["installedAt"], str) or not _TIMESTAMP.fullmatch(entry["installedAt"]):
154
161
  problems.append("installedAt is an RFC 3339 date-time")
155
162
  if not isinstance(entry["installedBy"], str) or not 1 <= len(entry["installedBy"]) <= 200:
@@ -161,7 +168,7 @@ def validate_provider_entry(entry: Any) -> List[str]:
161
168
 
162
169
 
163
170
  def _is_uri(value: Any) -> bool:
164
- return isinstance(value, str) and bool(_URI.fullmatch(value))
171
+ return isinstance(value, str) and bool(_URI.fullmatch(_IP_LITERAL.sub(r"\1h", value, count=1)))
165
172
 
166
173
 
167
174
  def _run_problems(run: Any) -> List[str]:
@@ -255,7 +262,7 @@ def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None
255
262
  # re-pins (CT-192), so the writer does not emit it yet.
256
263
  raise ProviderError("The manifest's name follows a backslash, which a reader that has not re-pinned to DEC-0145 refuses (CT-192). "
257
264
  "Write the path with / before the name: ~/.../fabric-agent.json, which every Fabric release reads, or "
258
- "C:/.../fabric-agent.json, which Fabric reads only after v0.3.15 (its repin, CO-479).")
265
+ "C:/.../fabric-agent.json, which Fabric reads only from v0.3.16 (its repin, CO-479).")
259
266
  problems = manifest_problems(entry)
260
267
  if problems:
261
268
  raise ProviderError("Provider entry is invalid: %s." % "; ".join(problems))
@@ -1,20 +1,15 @@
1
1
  ---
2
2
  name: building-fabric-services
3
3
  description: >-
4
- Use when running an agent as a Fabric service (fabric-service/0.1, its kits and probe): a local service (launchd,
5
- systemd, Windows), an ONLINE agent or dashboard on https, its dashboard link and kit (narrow panel, themes), data
6
- and cache paths, how callers reach and authenticate it, long jobs (fabric.job.*), spend report and limits, runner
7
- routes and model profiles, the estate record and delegation tokens, answering a Project's board requests, or packing
8
- and signing it. «сделай агенту дашборд», «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать
9
- на запросы с доски проекта», «расходы агента на модели», "make this agent a local service", "make an online
10
- dashboard a Fabric service", "add the well-known endpoint". NOT for a one-off script or cron job, a hosted product
11
- with no agent, the provider manifest (adapting-projects-to-fabric), a new agent's design (creating-fabric-agents), a
12
- session using Fabric or its board (working-with-fabric), or building the Dashboards app (window, tray).
4
+ Use when running an agent as a fabric-service/0.1 service, local or online: dashboard, auth, jobs, spend, board
5
+ requests. Triggers: "make this agent a local service", «сделай агенту дашборд», «локальный сервис агента». NOT
6
+ for a provider manifest (adapting-projects-to-fabric), a new agent (creating-fabric-agents) or a session
7
+ (working-with-fabric).
13
8
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
9
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
15
10
  metadata:
16
11
  author: PassionCode.ai
17
- version: "0.77.0"
12
+ version: "0.79.1"
18
13
  contract-version: "0.1.0"
19
14
  extension: "fabric-service/0.1"
20
15
  extension-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
@@ -32,6 +27,10 @@ Every rule below exists because a real service on this estate broke it. Treat th
32
27
  non-negotiable; the kit implements them so you call a function instead of
33
28
  re-deriving a rule.
34
29
 
30
+ ## When to use
31
+
32
+ The description above stays short for the skill listing budget; the whole reach, every trigger included, is in [references/when-to-use.md](references/when-to-use.md).
33
+
35
34
  ## Boundary
36
35
 
37
36
  Use it to create a service, to bring an existing local server under the protocol, or
@@ -221,7 +220,9 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
221
220
  2. `launchd_plist(...)` then `launchd_install(...)`: writes and lints the plist,
222
221
  `bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
223
222
  error, then polls the well-known document until **this** identity answers on this OS account's listener (HA-2).
224
- A label the operator disabled stays disabled: only a first install enables.
223
+ A label the operator disabled stays disabled: only a first install enables. On a headless Mac (no GUI login after a
224
+ FileVault restart) install a LaunchDaemon instead: the plist in `/Library/LaunchDaemons` with `user_name`, as root
225
+ (the lifecycle reference, *The system domain*).
225
226
  3. Uninstall: `launchd_uninstall` (waits for the unload, removes the plist, resets the
226
227
  override; `purge=True` removes data only when the operator asks), `remove_descriptor`.
227
228
  4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
@@ -244,28 +245,19 @@ while the operator types or a dialog is open, one panel's failure never blanks t
244
245
  others, and every error is a sentence with the next action. Read
245
246
  [the dashboard reference](references/dashboard.md) before building the page.
246
247
 
247
- The host shows the page in a pane beside the agent console that the operator can drag wide, so
248
- the page must hold from **360 CSS px** up: no sideways document scroll, navigation that wraps or
249
- folds into a More menu (never a tab strip that only scrolls sideways), wide tables and logs in
250
- their own focusable scroll box, sticky chrome at most 120 px, and the host's colour scheme and
251
- theme followed (`fabric-dashboard/0.1`, contract DEC-0036). **Build it from the dashboard kit**
252
- (`assets/dashboard-kit/`: `dashboard_kit.py install <static dir>`, seven AA themes, light and dark)
253
- and declare it with `extensions=fs.dashboard_extension()`. Read
254
- [the dashboard kit reference](references/dashboard-kit.md) for the markup, the themes, the
255
- declaration and how to upgrade a dashboard built before the kit: an upgrade you may make yourself,
256
- which the plugin's session-start notice offers in a repository that owns the service.
257
-
258
- **Every button has a tool** (`fabric-actions/0.1`, contract DEC-0121 AP-1). Each action the page lets a
259
- person take is a tool on the MCP surface with the same effect, checks, audit record and an idempotency key,
260
- registered with `server.add_tool(tool, handler, action={id, title, tool, gate})` (`gate` is `none`, `relayed`
261
- or `human` with its `humanStep`) and declared with `build_well_known(actions=server.action_table())` under
262
- `extensions/actions/0.1`. A `relayed` action called by the operator's own agent with their decision in
263
- `_meta.decision` is the operator's act: never answer it "operator session required" or send them to the page.
264
- A service adopts the relayed gate once Fabric's hub forwards decisions (CT-125), as the contract says; until then its tools
265
- answer as today, except the board responder's take-back action, declared `relayed` now: an agent whose client sets
266
- `_meta` calls it with the operator's decision; Claude Code and Codex reach it through the page until CT-125
267
- ([board.md](references/board.md); the reference service's `board.take-back`). Read the interop reference's *Actions* section for when to adopt, the gate, its hooks and its
268
- refusals.
248
+ The host shows the page in a pane the operator can drag wide, so it holds from **360 CSS px** up and follows the
249
+ host's colour scheme and theme (`fabric-dashboard/0.1`, contract DEC-0036). **Build it from the dashboard kit**
250
+ (`assets/dashboard-kit/`: `dashboard_kit.py install <static dir>`, seven AA themes, light and dark) and declare it with
251
+ `extensions=fs.dashboard_extension()`. [The dashboard kit reference](references/dashboard-kit.md) has the layout rules,
252
+ the markup, the themes, the declaration and how to upgrade a dashboard built before the kit, which the plugin's
253
+ session-start notice offers in a repository that owns the service.
254
+
255
+ **Every button has a tool** (`fabric-actions/0.1`, contract DEC-0121 AP-1): each action the page offers is an MCP tool
256
+ with the same effect, checks, audit record and idempotency key, registered with
257
+ `server.add_tool(tool, handler, action={id, title, tool, gate})` and declared with
258
+ `build_well_known(actions=server.action_table())` under `extensions/actions/0.1`. A `relayed` action called with the
259
+ operator's decision in `_meta.decision` is the operator's act, never answered "operator session required". When to
260
+ adopt, the gates, the hooks, the board responder's take-back and the refusals: the interop reference's *Actions* section.
269
261
 
270
262
  **Say what the agent uses** (`fabric-connections/0.1`, DEC-0096) so Fabric Dashboards draws its chain: the other
271
263
  agents, models, connectors, stores and channels, each with its state, from
@@ -377,12 +369,8 @@ things change and the kits implement each:
377
369
  example is [`scripts/sample-remote-service.mjs`](scripts/sample-remote-service.mjs): it runs
378
370
  with its own TLS or behind a platform router.
379
371
  2. **Secrets live on the platform:** the service token and the session key (32 bytes) are
380
- platform secrets, never in the repository, a URL or an argument vector. A provider key the
381
- remote service needs is issued and kept in Project Observatory, pushed to the platform, and
382
- the movement recorded with
383
- `python3 "$(project-observatory full-path)/tools/vault.py" moved <project> <env> <NAME> --how "<what moved, where, evidence>" --at <provider>`,
384
- so the platform's copy is known rather than invisible. Without Project Observatory, record
385
- where the key lives in the person's own secret manager.
372
+ platform secrets, never in the repository, a URL or an argument vector; a provider key stays in Project
373
+ Observatory or the person's secret manager, its move recorded ([remote placement](references/remote-placement.md), *On the platform*).
386
374
  3. **Register it on the operator's computer**, the installer side, run there:
387
375
  `registerRemote({ id, name, origin, token, source })` / `register_remote(...)` writes the token file
388
376
  (0600) and the descriptor, with `source.repository` naming the code's repository; pass the token from a file or
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.5 · Fabric's button (Fabric Agent Contract DEC-0040, FB-1…FB-8, FL-1…FL-6)
1
+ /* fabric-dashboard-kit@1.1.6 · Fabric's button (Fabric Agent Contract DEC-0040, FB-1…FB-8, FL-1…FL-6)
2
2
  Load it same-origin, after fabric-dashboard.css, with the service it opens Fabric on:
3
3
  <script src="fabric-button.js" defer data-service="example-agent.default" data-service-name="Example Agent"
4
4
  data-project="project-a" data-project-name="Project A"></script>
@@ -11,7 +11,7 @@
11
11
  (function (root) {
12
12
  'use strict';
13
13
 
14
- var VERSION = '1.1.5';
14
+ var VERSION = '1.1.6';
15
15
  var COOKIE = 'fabric_button';
16
16
  var GET_FABRIC = 'https://passioncode.ai/fabric';
17
17
  var ABSENT_AFTER_MS = 1500;
@@ -69,7 +69,10 @@
69
69
  if (piece === '') return;
70
70
  var at = piece.indexOf('=');
71
71
  var name = firstKey(at < 0 ? piece : piece.slice(0, at));
72
- values.set(name, (values.get(name) || []).concat(at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1))));
72
+ var value = at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1));
73
+ var list = values.get(name);
74
+ if (list) list.push(value); // appended in place, as the contract's secret-shape.ts does (FAA-245)
75
+ else values.set(name, [value]);
73
76
  });
74
77
  return values;
75
78
  }
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.5 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
1
+ /* fabric-dashboard-kit@1.1.6 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
2
2
  A Fabric service dashboard shown inside a host pane: usable from 360 CSS px up, navigation always
3
3
  reachable, wide content in its own scroll box, one vertical scroller, appearance from the host.
4
4
  Copy this file next to fabric-dashboard.js; do not edit it in place. Put the page's own rules in
@@ -1,4 +1,4 @@
1
- /* fabric-dashboard-kit@1.1.5 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
1
+ /* fabric-dashboard-kit@1.1.6 · fabric-dashboard/0.1 (Fabric Agent Contract DEC-0036)
2
2
  Load it same-origin with <script src="fabric-dashboard.js" defer></script>: no inline script, no
3
3
  eval, nothing from a CDN. It applies the host's appearance (ED-6, ED-8), folds navigation that
4
4
  does not fit into a More menu (ED-3), makes wide boxes keyboard-reachable (ED-4) and keeps
@@ -8,7 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  var KIT = 'fabric-dashboard-kit';
11
- var VERSION = '1.1.5';
11
+ var VERSION = '1.1.6';
12
12
  /* fabric-dashboard-kit:theme-ids:begin */
13
13
  var THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
14
14
  /* fabric-dashboard-kit:theme-ids:end */
@@ -148,6 +148,20 @@
148
148
  root.addEventListener('focus', applyAppearance);
149
149
  }
150
150
 
151
+ /* ---- resize observers write in the next frame (since 1.1.6) ----
152
+ A ResizeObserver callback that changes the size of what it watches, or of an ancestor, makes WebKit raise
153
+ "ResizeObserver loop completed with undelivered notifications". So a callback only schedules the work for the next
154
+ animation frame, cancelling one already scheduled, and the work itself skips a size it has already laid out. */
155
+ function nextFrame(work) {
156
+ var pending = null;
157
+ var raf = root.requestAnimationFrame;
158
+ return function () {
159
+ if (typeof raf !== 'function') { work(); return; }
160
+ if (pending !== null) root.cancelAnimationFrame(pending);
161
+ pending = raf.call(root, function () { pending = null; work(); });
162
+ };
163
+ }
164
+
151
165
  /* ---- navigation: fold what does not fit into More (ED-3) ---- */
152
166
  function foldNav(nav) {
153
167
  if (typeof nav.fdRefresh === 'function') { nav.fdRefresh(); return; } // folded already: refit, no second observers
@@ -212,8 +226,16 @@
212
226
  }
213
227
  }
214
228
  layout();
215
- if (typeof root.ResizeObserver === 'function') new root.ResizeObserver(function () { layout(); }).observe(nav);
216
- else root.addEventListener('resize', layout);
229
+ // The fit depends on the nav's width only: a callback for a height the fold itself changed lays nothing out.
230
+ var laidOutWidth = null;
231
+ var refit = nextFrame(function () {
232
+ var width = nav.clientWidth;
233
+ if (width === laidOutWidth) return;
234
+ laidOutWidth = width;
235
+ layout();
236
+ });
237
+ if (typeof root.ResizeObserver === 'function') new root.ResizeObserver(refit).observe(nav);
238
+ else root.addEventListener('resize', refit);
217
239
  // A page that moves aria-current (its route changed) gets a fresh summary and fit, without telling the kit.
218
240
  if (typeof root.MutationObserver === 'function') {
219
241
  new root.MutationObserver(layout).observe(nav, { subtree: true, attributes: true, attributeFilter: ['aria-current'] });
@@ -255,12 +277,16 @@
255
277
  var header = chrome.querySelector('.fd-header');
256
278
  var lift = 0;
257
279
  if (height > limit && header) lift = Math.min(Math.ceil(header.getBoundingClientRect().height), height);
258
- html.style.setProperty('--fd-chrome-top', lift ? -lift + 'px' : '0px');
259
- html.style.setProperty('--fd-chrome-h', Math.max(0, height - lift) + 'px');
280
+ var top = lift ? -lift + 'px' : '0px';
281
+ var pinned = Math.max(0, height - lift) + 'px';
282
+ // An unchanged value is not written again: a write restyles the page and can wake the observer once more.
283
+ if (html.style.getPropertyValue('--fd-chrome-top') !== top) html.style.setProperty('--fd-chrome-top', top);
284
+ if (html.style.getPropertyValue('--fd-chrome-h') !== pinned) html.style.setProperty('--fd-chrome-h', pinned);
260
285
  }
261
286
  set();
262
- if (typeof root.ResizeObserver === 'function') new root.ResizeObserver(set).observe(chrome);
263
- root.addEventListener('resize', set);
287
+ var reset = nextFrame(set);
288
+ if (typeof root.ResizeObserver === 'function') new root.ResizeObserver(reset).observe(chrome);
289
+ root.addEventListener('resize', reset);
264
290
  }
265
291
 
266
292
  function init() {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabric-dashboard-kit",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "profile": "fabric-dashboard/0.1",
5
5
  "decision": "DEC-0036",
6
6
  "declarationKey": "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1",
@@ -15,9 +15,9 @@
15
15
  "contrast"
16
16
  ],
17
17
  "files": {
18
- "fabric-dashboard.css": "cc7a96fa2c5eeb03d113e7a543ed88d43ce78c2808c411de199ea0fc6d55b957",
19
- "fabric-dashboard.js": "5df01193c590a1d8aa82a521954902fecf2d7f655df482165f90d7291b00087f",
20
- "fabric-button.js": "e945b3252f9a246826d06dbe7bd4be66e82d539a33728c3d3043fd61d4cbd681",
18
+ "fabric-dashboard.css": "7a90bbe1f1a2170df5dac0f596a3f302167bef9a7243457bb21dd0eb34f4d359",
19
+ "fabric-dashboard.js": "146ec9a0fd50af2df3245c02ec7dfba75b7d4e8cb5476fedb5ef727869fef1cc",
20
+ "fabric-button.js": "60c2b6d44571213e0237c29a94a4cf220f9de3efcfa697ac6a33777d1ad25fd6",
21
21
  "themes.json": "511839f1a1a83eb37fadfd132791474497d5c28c5091700b9aa31ad076e359b5"
22
22
  },
23
23
  "history": {
@@ -54,6 +54,11 @@
54
54
  "fabric-dashboard.css": "cc7a96fa2c5eeb03d113e7a543ed88d43ce78c2808c411de199ea0fc6d55b957",
55
55
  "fabric-dashboard.js": "5df01193c590a1d8aa82a521954902fecf2d7f655df482165f90d7291b00087f",
56
56
  "fabric-button.js": "e945b3252f9a246826d06dbe7bd4be66e82d539a33728c3d3043fd61d4cbd681"
57
+ },
58
+ "1.1.6": {
59
+ "fabric-dashboard.css": "7a90bbe1f1a2170df5dac0f596a3f302167bef9a7243457bb21dd0eb34f4d359",
60
+ "fabric-dashboard.js": "146ec9a0fd50af2df3245c02ec7dfba75b7d4e8cb5476fedb5ef727869fef1cc",
61
+ "fabric-button.js": "60c2b6d44571213e0237c29a94a4cf220f9de3efcfa697ac6a33777d1ad25fd6"
57
62
  }
58
63
  }
59
64
  }
@@ -26,8 +26,10 @@ skill's business (working-with-fabric, `references/project-board.md`).
26
26
 
27
27
  A **request** is addressed to a Project and one of its capabilities (`video.render`), never to a service. One
28
28
  **responder slot** exists per Project and capability; a service holds it under a **`board.respond` grant** the
29
- operator gives in Fabric (Agents & access): callee `fabric`, capability `board.respond`, resource
30
- `board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing.
29
+ operator gives in Fabric (Settings → Agent access): callee `fabric`, capability `board.respond`, resource
30
+ `board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing. The capability is
31
+ never `project.work`: DEC-0140 PR-1 reserves it for a Project's primary responder, a coding session, and `Responder`
32
+ refuses it with `invalid_arguments` (FAA-244).
31
33
 
32
34
  1. Ask once, through the hub's door: `HubClient().request_board_access(agent_id, project_id, [capability], reason)`
33
35
  (`requestBoardAccess`). Keep the answer's `pollSecret` in memory or a 0600 file.
@@ -37,8 +39,9 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
37
39
  3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
38
40
  checks the pid it names is alive, tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
39
41
  another OS account's process, or one whose owner cannot be told, is `HubError` code `foreign-user`, retryable, with
40
- nothing sent; a retry never clears a restricted macOS table, `table-restricted` in protocol.md, FAA-272), and only then sends the credential (a session already running maps that refusal, as any transport
41
- failure, to `board.down` with reason `unreachable` until FAA-252). A missing grant is `HubError` code
42
+ nothing sent; a retry never clears a restricted macOS table, `table-restricted` in protocol.md, FAA-272), and only then sends the credential (a session already running carries that refusal as `BoardError` code
43
+ `foreign-user`, one of `SESSION_CODES`, to `board.down` with reason `foreign-user` and a sentence that says nothing
44
+ was sent, never `unreachable`'s "a write may have been stored", FAA-252). A missing grant is `HubError` code
42
45
  `not_authorized` whose `data.ask` holds the arguments to ask with.
43
46
 
44
47
  ## Wiring it into the service
@@ -302,7 +305,7 @@ held and not transferred). The responder keeps that move instead of undoing it:
302
305
  after the slot had moved past the record, reads as a move: the safe side; a slot such a replay took is left to lapse
303
306
  unused. A generation written `2.0` is `2`.
304
307
  - The operator may move the slot back to this session in Fabric (DEC-0140 PR-2), which this client cannot hear. No
305
- Fabric release offers the operator's move yet (Fabric 0.3.15 has none: the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
308
+ Fabric release through v0.3.19 (2026-11-11) offers the operator's move (the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
306
309
  so today every move is another session taking the slot and take-back is the route back; the check below is ready
307
310
  for the move. While
308
311
  a slot is moved the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the
@@ -81,7 +81,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
81
81
  | Receipts | every status-200 answer records one, and so does an attempt that may have been billed without an answer to read (a timeout, a cut or refused connection, a 200 over the size cap or not JSON: no tokens, `costUsd: null`, `tokensMeasured: false`; over-counting a refused connection is the safe side): the served model, the provider's tokens (cache reads and writes kept apart), `costUsd` from `usage.cost`, else from the price list (`costBasis: price-list`), else `null` with `tokensMeasured: false`; `route: key`, the client's `project_id` and the call's `task_run_id`. A request the provider answered with a 4xx or 5xx records nothing: it was not billed |
82
82
  | What is an answer | `finish_reason` `stop` or `tool_calls` with content or tool calls; `error` is retried on the same model; `length` (the budget ran out, reasoning included), `content_filter` and empty content go to the fallback |
83
83
  | Retries | 408, 425, 429, 500, 502, 503, 504 and connection errors on the same model, `retries` times (default 1), `Retry-After` honoured, within one deadline (`deadline_s` 120) for the whole call |
84
- | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model |
84
+ | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model; a loopback `base_url` another OS account's process holds (kind `foreign-user`, nothing sent, nothing counted): the fallback would reach the same listener (FAA-257) |
85
85
  | The fallback | required, of another vendor (the part before `/` differs) |
86
86
  | Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
87
87
  | What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
@@ -90,7 +90,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
90
90
  | Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
91
91
 
92
92
  `OpenRouterError.attempts` lists every attempt as `{model, status, outcome}` (`answered`, `retry`, `error`,
93
- `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`); nothing in it holds the key or the
93
+ `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`, `foreign-user`); nothing in it holds the key or the
94
94
  prompt. `totals(receipts)` gives calls, priced spend and unpriced calls for a dashboard line. Choosing the models
95
95
  themselves: creating-fabric-agents, `references/models-and-providers.md`.
96
96
 
@@ -59,7 +59,7 @@ Never edit the copied files: the page's own rules go in its own stylesheet, cons
59
59
  | Grids | `.fd-grid` (cards, 320 px columns), `.fd-grid--tiles` (160 px), `.fd-grid--wide` (480 px); `--fd-col` sets your own |
60
60
  | Wide content | `<div class="fd-scroll" data-fd-label="Jobs table"><table class="fd-table">…</table></div>`, `<pre class="fd-scroll">` |
61
61
  | Atoms | `.fd-card`, `.fd-tile`, `.fd-chip` + `.fd-dot` + `.fd-s-ok\|warn\|danger\|info`, `.fd-btn`, `.fd-btn--primary`, `.fd-input`, `.fd-select`, `.fd-banner`, `.fd-empty`, `.fd-pairs` |
62
- | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3) |
62
+ | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3). Since 1.1.6 a resize refits in the next animation frame and only when the nav's width changed, and the chrome's `--fd-chrome-h` and `--fd-chrome-top` are written the same way and only when they change, so no observer resizes what it watches inside its own callback (WebKit's "ResizeObserver loop completed with undelivered notifications" at 390 px, seen on a service page) |
63
63
  | Own appearance controls | `<select data-fd-theme-picker>`, `<select data-fd-scheme-picker>` (the script fills them; hidden while the host owns appearance) |
64
64
 
65
65
  Tokens are the workbench layer (`--bg`, `--panel`, `--panel-2`, `--ink`, `--muted`, `--border`,
@@ -125,11 +125,43 @@ its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
125
125
  the transport. Unknown or failed host status produces no executable local action.
126
126
  A separately labelled copyable link for diagnosis is not an open action.
127
127
 
128
- Add this check to the generated project's dashboard completion/notification
129
- renderer before delivery. A raw-HTTP primary action with an available host is the
130
- negative fixture; a valid native action is the positive one. Keep diagnostics
131
- separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
132
- or instructions alone do not prove all final answers from every agent are filtered.
128
+ The gate is **agent-side**: the agent runs it from this skill's directory before it
129
+ delivers an action. It is never copied into the generated project and never run by
130
+ that project's CI: the adapter's versioned plugin folder has no stable path there, so
131
+ its result in a project's pipeline would always be NOT_RUN. A raw-HTTP primary action
132
+ with an available host is the gate's negative fixture; a valid native action is the
133
+ positive one. Keep diagnostics separate. This gate checks structured actions, not
134
+ arbitrary prose: a CLI invocation or instructions alone do not prove all final answers
135
+ from every agent are filtered.
136
+
137
+ The generated project pins the same negative fixture in its own test suite, without
138
+ calling the gate: a test that renders its completion and notification output with an
139
+ available host and fails when a raw loopback `http_url` or a legacy `open?service=`
140
+ link appears in it. The snippet is self-contained; `render` stands for the project's
141
+ own renderer:
142
+
143
+ ```python
144
+ import re
145
+ import unittest
146
+
147
+ RAW_LOOPBACK = re.compile(r"https?://(?:127\.0\.0\.1|localhost|\[::1\])")
148
+ LEGACY = "fabric-dashboards://open?service="
149
+ RESOLVED = {"service": "example-agent.default",
150
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
151
+ "http_url": "http://127.0.0.1:47195/dashboard"}
152
+
153
+
154
+ def render(resolved, host_state): # replace with the project's completion/notification renderer
155
+ raise NotImplementedError
156
+
157
+
158
+ class RenderedDashboardLinks(unittest.TestCase):
159
+ def test_rendered_output_has_no_raw_loopback_or_legacy_link(self):
160
+ out = render(RESOLVED, host_state="available")
161
+ self.assertIn(RESOLVED["open_link"], out)
162
+ self.assertIsNone(RAW_LOOPBACK.search(out), out)
163
+ self.assertNotIn(LEGACY, out)
164
+ ```
133
165
 
134
166
  ## Degradation
135
167
 
@@ -127,7 +127,10 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
127
127
 
128
128
  `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
129
129
  a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
130
- `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
130
+ `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. On loopback
131
+ http it tells the listener's owner from the socket table before a byte is sent and reads no key set another OS
132
+ account's process serves (DEC-0154 HA-2, `fabric_peer_owner`; the read fails `foreign-user`, so the set stays unread,
133
+ FAA-255); no proxy from the environment is used there. Where the socket table hides other processes (macOS 27.2 with an ad-hoc-signed Homebrew Python, FAA-267), the owner cannot be told, so a loopback key set fails `foreign-user` and every delegated call is refused `issuer-unavailable`: run the service on Apple's `/usr/bin/python3`, or give the verifier the key set as a file. The
131
134
  Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
132
135
  reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
133
136
  and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
@@ -60,6 +60,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
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
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,13 @@ 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). `well_known_of(origin)` / `wellKnownOf(origin)` read
55
+ it so a status tells HA-2's order apart: None / null when nothing answers (`down`), a `ForeignUserAnswer` whose
56
+ `reason` starts `foreign-user` or `table-restricted`, else the document; `fetch_well_known` keeps answering None for
57
+ both (FAA-259). Every loopback spelling of an origin (`127.0.0.1`, another `127/8` address, `localhost`, `[::1]`) is read
58
+ on a checked connection (FAA-257). `launchd_install` takes an injected reader, `fetch_well_known=` and
59
+ `fetch_job_well_known=`, as the Node `launchdInstall` takes `fetchWellKnown` and `fetchJobWellKnown`.
53
60
  `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
54
61
  full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
55
62
  that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
@@ -76,7 +83,7 @@ systemd and Task Scheduler do not poll yet, FAA-253).
76
83
  |---|---|---|
77
84
  | `Label` | reverse-DNS, stable (`com.example.example-agent`) | the host controls the job by label |
78
85
  | `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 |
86
+ | `RunAtLoad` | `true` | back at every login (a LaunchDaemon: at every boot) |
80
87
  | `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
81
88
  | `ThrottleInterval` | `10` | bounds a crash loop |
82
89
  | `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 +109,13 @@ systemd and Task Scheduler do not poll yet, FAA-253).
102
109
  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
110
  `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
104
111
  vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
112
+ A LaunchDaemon reads its answer differently: *The system domain*, below.
105
113
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
106
114
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
107
115
  and the one before it stay for rollback, older ones go (LC-11, LC-15).
108
116
  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.
117
+ (with `service_id`) removes data, logs and cache when the operator asks; a LaunchDaemon refuses `purge` (*The system
118
+ domain*, below).
110
119
 
111
120
  **The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
112
121
  `disable` (off survives a login) and starts it with `enable` + `bootstrap`; restart is
@@ -123,6 +132,68 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
123
132
  off. Tests use a throwaway label or a fake `launchctl` first on `PATH`, never the
124
133
  operator's real jobs.
125
134
 
135
+ ## The system domain
136
+
137
+ A `gui/<uid>` job (a LaunchAgent) runs only while its user is logged in. A headless Mac that a FileVault restart
138
+ (`fdesetup authrestart`) leaves with no GUI login, or an SSH session of a user with no GUI login, has none: `bootstrap`
139
+ answers error 125 ("Domain does not support specified action") and the installer's error says to remove the LaunchAgent
140
+ it wrote (`launchd_uninstall`) and use the system domain (FAA-278). There the job is a **LaunchDaemon**: launchd starts
141
+ it at boot, before any login, as the account its `UserName` names.
142
+
143
+ - **The plist**: `launchd_plist(..., user_name="<short name>")` (Node: `userName`) writes `UserName`, which a system
144
+ install requires (without it the job would run as root, which every client refuses); the file goes in
145
+ `/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
146
+ ignored); `domain="system"` (Node: `domain: 'system'`) says so for any path, and an explicit domain wins over the
147
+ path. Everything else is a LaunchAgent's.
148
+ `UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
149
+ - **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
150
+ 2 above, for example with `sudo -u <user> -H`), so they live in that user's home with its modes, and the plist's
151
+ working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
152
+ and at uninstall `remove_descriptor` runs as the `UserName` again. A service moved from a LaunchAgent is uninstalled
153
+ as one first, so launchd never holds both. The descriptor's `lifecycle.plist` names the path exactly,
154
+ `/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
155
+ - **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
156
+ Mac, so a LaunchDaemon's label is unique on the machine, not per account (put the account in it on a Mac with a
157
+ personal and an organization account); an install refuses, before writing anything, a label another plist holds
158
+ (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
159
+ names another `UserName` or another label; an uninstall refuses a label another plist holds before its `bootout`,
160
+ which would stop that plist's job, so pass it the label the plist at its path names.
161
+ - **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
162
+ anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
163
+ `launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
164
+ does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
165
+ again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
166
+ descriptor it rewrites) as the `UserName` as an install does, and runs only the plist and `launchd_install` as root; a
167
+ 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
168
+ `bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
169
+ `kickstart -k system/<label>`; a host or broker without root reports the job read-only.
170
+ - **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
171
+ `launchctl print system/<label>`, and `launchd_override(label, "system")` asks `launchctl print-disabled system`
172
+ (Node: the `domain: 'system'` option), both of which any account may read; the probe's `lifecycle.one-copy` reads the
173
+ domain from the descriptor's plist the same way.
174
+ - **The answer the installer waits for**: root reads the service's listener as another account's, so the installer takes
175
+ the job's pid from `launchctl print system/<label>` and reads the token-free well-known document only on a connection
176
+ that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
177
+ an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
178
+ `http://127.0.0.1:<port>`, where nothing could prove it. Where the socket table is restricted it accepts a port that
179
+ pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (CT-261). A `ProgramArguments` wrapper that
180
+ starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
181
+ install refused after its bootstrap leaves the job loaded, and its plist starts it again at boot; the error says so,
182
+ 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
183
+ in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
184
+ it. A descriptor field for the domain is the contract's CT-234, open.
185
+ - **A contract exception, accepted in principle.** At the pin, HA-2 reads a launch daemon's listener as another
186
+ 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
187
+ root installer reads its own job's token-free well-known answer anyway, proved by launchd's pid, that pid's uid and its
188
+ bind on exactly `127.0.0.1` (`lsof -p`, never a wildcard), and sends no credential; under a restricted socket table the
189
+ 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
190
+ principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
191
+ text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
192
+ Every other client keeps HA-2 as written.
193
+ - **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
194
+ its plist into `gui/<uid>`. Fabric Dashboards from 0.8.39 takes the domain from the plist's folder and offers no verbs
195
+ for a system job; the lifecycle broker is still asked (FAA-287).
196
+
126
197
  ## One copy under a supervisor
127
198
 
128
199
  Started by hand, a copy that finds `service.lock` held prints one sentence naming the