@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.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 (127) hide show
  1. package/CHANGELOG.md +200 -3
  2. package/README.md +11 -6
  3. package/SECURITY.md +8 -5
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  111. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  112. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  113. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  114. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  115. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  116. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  117. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  118. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  124. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  125. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
  126. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
  127. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -59,8 +59,10 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.5`; Observatory's 1.3.x libproc proof is FAA-292), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
62
+ | L0 | `fabric_socket_proof` | this OS account's own TCP sockets, read from its own processes, and the two proofs they give on a restricted macOS table (DEC-0155 RT-2, FAA-292; FAA-272): `accepted` (the server end of a connection held by one of this account's processes) and `own_listener` (its listener on exactly 127.0.0.1); Python reads them through libproc (Project Observatory's `peer_owner` 1.3.1 reader, calibrated on a pair of its own) and lsof when libproc is unavailable, the Node twin through `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`; `fabric_peer_owner` asks it when it sits beside it |
63
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries) and `a69c6fee` (1.3.1), with the readings its header names (`1.3.1+kit.7`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) `fabric_socket_proof`, when beside it, proves a server end or listener this account's processes hold (RT-2), and after it a caller may vouch with a pid it started, proved by `lsof -p` (RT-3) |
63
64
  | 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)) |
65
+ | L0 | `fabric_tailnet_identity` | a person's identity at every door (dashboard, MCP, the service's own) of a service published through `tailscale serve` (FAA-324): `Tailscale-User-Login` believed only when the service listens on a loopback address and the peer is loopback, groups and per-door rules from the organization's `tailnet-groups/1` file (schema `tailnet-groups.schema.json`, shipped beside it as data), read again on change and closing every door while unreadable or invalid; `TailnetIdentity`, `decide`, `load_groups`, `send_refusal`, every refusal 403 with a code and a sentence ([surfaces-and-auth.md](surfaces-and-auth.md#a-groups-file-at-every-door)) |
64
66
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
67
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
66
68
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
@@ -79,7 +81,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
79
81
  | L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
80
82
  | L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
81
83
  | L3 | `fabric_load` | the call queue and the load report of DEC-0104 (FAA-289): `job_caller_key`, `next_queued_job`, `admit_job`, `queue_status_message`, `read_queue_position`, FAC-SEM-131 to 133, the `service-load/1` and `extensions/load/0.1` shapes, `load_extension`; imports `fabric_connections` for ajv's `date-time` ([interop](interop.md#a-nodes-call-queue)) |
82
- | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
84
+ | L3 | `fabric_lock` | the instance lock, its watch where the lock has no path-keyed name (macOS, FAA-303), and the supervisor marker (LC-01, DEC-0057) |
83
85
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
84
86
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
85
87
  | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document, the loopback HTTP server that refuses another OS account's peer before reading it (`LoopbackHTTPServer`, Node `loopbackServer`; FAA-227) |
@@ -87,6 +89,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
87
89
  | L3 | `fabric_auth` | the token, the network guard, login codes |
88
90
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
89
91
  | L3 | `fabric_logs` | rotating logs, the stop signal and the drain (LC-12) |
92
+ | L3 | `fabric_ops_digest` | the operations digests: `ops.digest.hourly@1` and `ops.digest.daily@1` folded from the service's own counts, FAC-SEM-166 and FAC-SEM-167, the OD-9 declaration (DEC-0153, FAA-206); its reference is [ops-digest.md](ops-digest.md) |
90
93
  | L3 | `fabric_supervisors` | launchd (a LaunchAgent, or a LaunchDaemon in the system domain, FAA-278), systemd user units, Task Scheduler (DEC-0032, LC-14) |
91
94
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
92
95
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
@@ -102,6 +105,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
102
105
  | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
103
106
  | L4 | `fabric_http` | outbound HTTP: deadline, bounded retries, `Retry-After`, size cap ([connectors](connectors.md)) |
104
107
  | L4 | `fabric_openrouter` | OpenRouter chat: own key, spend ceiling, receipts, fallback ([connectors](connectors.md)) |
108
+ | L4 | `fabric_a2a_server` | A2A 1.0 serving and calling: the Agent Card from capabilities, the JSON-RPC binding over the interop job store, a bounded client with the operator's origin allowlist (FAA-10); its reference is [a2a.md](a2a.md) |
105
109
 
106
110
  Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service_rules.py`,
107
111
  `check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
@@ -106,10 +106,14 @@ systemd and Task Scheduler do not poll yet, FAA-253).
106
106
  then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
107
107
  (DEC-0154 HA-2: another account's listener, or one whose owner cannot be told, is `foreign-user` and never a
108
108
  successful install; the error names it). Where the socket table hides other processes (macOS 27.2 under an
109
- ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`; a listener it
110
- cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
111
- `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
112
- vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
109
+ ad-hoc-signed interpreter), the job's accepted socket in one of this account's processes proves it first (DEC-0155
110
+ RT-2, `fabric_socket_proof`, FAA-292), then the installer vouches with its own job's pid from `launchctl print`,
111
+ for a listen on exactly `127.0.0.1` alone (RT-3; a wildcard bind is never vouched for since FAA-312); a listener
112
+ neither proves reads `table-restricted` (beside HA-2's `foreign-user`, which the pinned contract's definition
113
+ covers) and the error names the way round it (FAA-267). The vouch takes the job's own pid, so a `ProgramArguments`
114
+ wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot vouch for it,
115
+ though RT-2 still proves its child's accepted socket: on such a host start the server with `exec`, or run the
116
+ installer from Apple's `/usr/bin/python3`.
113
117
  A LaunchDaemon reads its answer differently: *The system domain*, below.
114
118
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
115
119
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
@@ -381,6 +385,26 @@ holds the same name beside its file lock, so the two kits exclude each other eve
381
385
  name needs Node 22 or later: Node 18 and early 20 collapse every abstract socket name into one, and later 20 pads it so
382
386
  no Python copy or probe sees it, so the Node kit refuses to start there with a sentence naming Node 22 (FAA-72).
383
387
 
388
+ **A lock that moves under its holder (FAA-303).** `acquire` checks that the inode it locked is the file at the path
389
+ (FAA-293), which closes the race between two copies starting at once. A folder swapped under a copy that already holds
390
+ the lock (a data import replacing the data folder) is a second case. On Linux and Windows the path-keyed name above
391
+ still keeps a second copy out. On macOS the lock is `flock` (Python) or `O_EXLOCK` (Node), both on an inode, so a
392
+ second copy locks the new file and starts. There `hold_single_instance` / `holdSingleInstance` start a watch on the
393
+ held lock (`InstanceLock.watch` / `InstanceLock#watch`): every `LOCK_WATCH_SECONDS` (2 s; one `stat` and one `fstat`)
394
+ the holder compares the inode it locked with the file at the path, and once the path names another file (the imported
395
+ folder's lock, or the one a second copy made) it stops at once with 75 and one sentence naming the path
396
+ (`lost_lock_message` / `lostLockMessage`), so the copy started on the new folder runs alone. The two copies overlap for
397
+ at most one interval. The stop is abrupt on purpose (`stop_lost_holder` / `stopLostHolder`: `os._exit`,
398
+ `process.exit`): a graceful shutdown would write the old copy's state into the folder now at the path, which is not the
399
+ one it read. Under a supervisor the stopped copy is restarted and takes the new folder's lock, or waits as above while
400
+ another copy holds it. A path that is gone or cannot be
401
+ read now (a folder removed, a permission, an I/O error) is read as nothing there yet and tried again next time: a
402
+ second copy has to make a file there to start, and the watch then sees that file. `release` ends the watch. Pass
403
+ `on_lost` / `onLost` to `hold_single_instance` / `holdSingleInstance` (or call `watch` yourself on an `InstanceLock`) to
404
+ react otherwise. A path-keyed name on macOS was weighed and left: it needs a file outside the data folder, in a folder
405
+ the system never cleans and every copy of every user can write, which the standard library cannot promise; and a
406
+ watch also stops a holder that would otherwise go on writing stale state into the imported folder.
407
+
384
408
  **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
385
409
  token files*): the kits write a protected list: the user, SYSTEM and Administrators, set before the
386
410
  token's bytes go in, any entry already there dropped. On read, `windows_token_acl_problem` /
@@ -0,0 +1,75 @@
1
+ # Operations digests: a service's own hourly and daily counts
2
+
3
+ The contract's DEC-0153 ([ops-digest.md at the pin](https://github.com/passioncode-ai/fabric-agent-contract/blob/93e7db164c7829e2783b4b7705c4113bb5201131/docs/specification/ops-digest.md))
4
+ asks every product to count its own operations log and keep two kinds of lines on its own machine: one
5
+ `ops.digest.hourly@1` line for each hour it observed itself running in, an empty one included, and one
6
+ `ops.digest.daily@1` line for each closed UTC day that has hourly lines. No model reads the log; the fold is
7
+ arithmetic. The kit's module is `fabric_ops_digest.py` and its Node twin `fabric-ops-digest.mjs` (FAA-206 part 2).
8
+
9
+ ## Use it
10
+
11
+ ```python
12
+ from fabric_ops_digest import OpsDigest, ops_digest_extension, outcome_of
13
+
14
+ digests = OpsDigest(paths["data"] / "digests") # a folder inside the data folder, made 0700
15
+ digests.start() # writes the hour the last run kept, folds missed days
16
+ digests.record("mcp.tools/call", "ok") # each record of the log, before the log samples it (OD-3)
17
+ digests.record("board.tick", outcome_of(word, {"timeout": "failed", "denied": "unknown"}))
18
+ digests.tick() # once a minute or so: an hour with a tick is written, empty if quiet
19
+ digests.flush() # now and then: writes a closed hour and a closed day
20
+ digests.stop() # at the drain: keeps the open hour for the next run
21
+ ```
22
+
23
+ Node: `new OpsDigest(dir)`, then `start()`, `record(op, outcome)`, `tick()`, `flush()`, `stop()`; `outcomeOf`,
24
+ `opsDigestExtension`.
25
+
26
+ - **Names (OD-2).** `op` names the operation, never a value of it: a lower-case letter, then letters, digits and `_`
27
+ in segments joined by `.`, `:`, `/` or `-`, at most 64 characters (`board.tick`, `mcp.tools/list`). A name the
28
+ grammar refuses, or one shaped like a credential or holding a home folder (FAC-SEM-167), raises `OpsDigestError` at
29
+ `record`, so no line every reader skips is ever written. The error names the place, never the value.
30
+ - **Outcomes (OD-3).** `ok` and `failed` count as themselves, every other word as `unknown`. A log with its own words
31
+ maps them with `outcome_of(word, mapping)` and publishes that map in its own documentation; a setup answer (a
32
+ missing slot, DEC-0149), a refusal or a skip is `unknown`, never `failed`, so an unfinished setup makes no rise.
33
+ - **The overflow name.** A day keeps at most 4095 named operations; the rest are counted under `ops.overflow` (pass
34
+ `overflow=` to choose another and publish it with the outcome map).
35
+ - **Hours (OD-1).** An hour is written when it closes. Hours the service did not observe (a laptop asleep) are never
36
+ filled. The hour the service stops in is kept in `kept-hour.json` and written at the next run, compared with the
37
+ hour before only when that one is the last written; a restart within the same hour resumes it, so no hour has two
38
+ lines.
39
+ - **Days (OD-5).** Each closed UTC day with hourly lines gets one daily line, at the next run if the service was not
40
+ running when the day closed (catch-up).
41
+ - **Files (OD-6).** `hourly-YYYY-MM.jsonl` and `daily-YYYY-MM.jsonl`, one JSON line each, files 0600 in a 0700
42
+ folder. `read_digests(folder, kind)` reads them as a reader does: a bad line skipped, the first line of an hour or a
43
+ day kept.
44
+ - **Rises (OD-4, OD-8).** A rise is at least 10 failures in an hour and at least twice the hour before's (none counts
45
+ as one); it is in the hourly line's `risen`. A service that shows findings shows each rise of its newest closed hour
46
+ with the operation, the failures and the hour before's, never as the cause of a failure.
47
+ - **Nothing but counts (OD-7).** No log line, path, identifier or text ever enters a digest.
48
+
49
+ ## Tell a collector where the daily lines are (OD-9)
50
+
51
+ A local descriptor that names `paths.data` declares the folder under its `extensions`:
52
+
53
+ ```python
54
+ descriptor["extensions"] = ops_digest_extension([{"dir": "digests", "source": "app"}])
55
+ ```
56
+
57
+ `ops_digest_extension` checks the grammar of `dir` (relative to the data folder, forward slashes, no `.`, `..`, `:`,
58
+ `~` or drive), home folders and credential shapes, and that each `dir` and `source` is named once. The contract's
59
+ FAC-SEM-168 (its caseless fold of `dir` among other things) stays the authority; a remote descriptor never carries the
60
+ key. A collector in the same OS account reads the daily lines and sends them on unchanged; who collects and how is
61
+ Project Observatory's, not the kit's.
62
+
63
+ ## The retro after work
64
+
65
+ A host that hands sessions the `retro-after-work` obligation counts each ask as one record of the operation
66
+ `retro-after-work` (RW-4): `ok` when it saw a lesson or an `insight/1` line written, `failed` when such a write was
67
+ refused, `unknown` otherwise. working-with-fabric's hooks remind a Claude Code session; the error classes the hooks
68
+ would count wait on FAA-202.
69
+
70
+ ## Checks
71
+
72
+ `fold_hour` and `fold_day` are the contract's reference folds, run in `test/test_fabric_ops_digest.py` and
73
+ `test/fabric-ops-digest.test.mjs` against the contract's vectors (`test/fixtures/ops-digest/vectors.json`);
74
+ `ops_digest_rules` is FAC-SEM-166 and FAC-SEM-167, run against the contract's positive and semantic fixtures
75
+ (`test/fixtures/ops-digest/rules.json`).
@@ -29,7 +29,7 @@ The person writes the descriptive part; `pack` fills in the rest.
29
29
  | You write | `pack` writes |
30
30
  |---|---|
31
31
  | `schema` (`fabric-package/1`), `kind` (`agent` or `skill-set`), `id` (kebab-case, the plugin name), `version` (semver), `publisher` (a name, never a key), `licence` (SPDX) | `agentRef` = `urn:fabric:agent:<publisher>:<id>`, after `publisher` (an agent only; a hand-written one that differs is refused) |
32
- | an agent: `resultSignal {name, says}`, `evals {suite, receipt {ranAt, suite, passed, failed, skipped?}}`, and as needed `provider`, `service`, `secrets[]` (slots: name and purpose, never a value), `requires`, `costPresets`, `state[]`, `dependencies[]` | `files[]`: every file but `fabric.package.json`, in UTF-16 order, each with `sha256:` and its hex digest |
32
+ | an agent: `resultSignal {name, says}`, `evals {suite, receipt {ranAt, suite, passed, failed, skipped?}}`, and as needed `provider`, `service`, `secrets[]` (slots: name and purpose, never a value), `requires`, `costPresets`, `state[]`, `dependencies[]`; an agent with no `provider` (a consumer agent, DEC-0071) must carry `skills` and `requires` with `requires.mcp`, which the schema refuses it without (FAA-190) | `files[]`: every file but `fabric.package.json`, in UTF-16 order, each with `sha256:` and its hex digest |
33
33
  | a skill set: `layer`, `skills` | `digest` |
34
34
 
35
35
  The evals receipt's `suite` is the SHA-256 of the suite **as packed**. When the suite changes, run it again and write that
@@ -66,8 +66,9 @@ file names needs an ignore line of its own at the new path); a link whose target
66
66
  the way to it (an ancestor, or a folder a link above it led into) is removed, so the copy ends; a target that leaves
67
67
  that tree or lies in what `.gitignore` keeps out, such as an instance folder in the checkout, is removed, since it is
68
68
  the instance or another project. Source the agent shares from outside the tree (a monorepo's common code, linked or
69
- imported by a relative path) is copied into the agent's tree at the path it is reached by and committed before
70
- packing. List each link removed and review it: a missing file is not seen by `pack` or `verify`. Leave out dependency
69
+ imported by a relative path) is copied into the agent's tree and committed before packing, and every import that
70
+ reached it by a relative path leaving the tree (`../../common/x`) is changed to the copied path, or the package
71
+ imports a path that is not in it (FAA-190). List each link removed and review it: a missing file is not seen by `pack` or `verify`. Leave out dependency
71
72
  folders (`node_modules`, virtual environments, which hold install paths and binaries), what `.gitignore` keeps out as
72
73
  instance (the real configuration, an instance folder in the checkout) and the files `pack` refuses by name (a
73
74
  credential-free `.npmrc` that `.fabric-source-ignore` names for the check included). `pack` does not read
@@ -76,8 +77,9 @@ and a private `*.jwk` by their names: keep them in the instance.
76
77
 
77
78
  It refuses to list what must not ship, and names each: a symlink, a special file, `.git`, `__pycache__`, `.DS_Store`,
78
79
  `*.pyc`, and files that are credentials by name (`.env` and `.env.*` but `.env.example|sample|template`, `*.pem`, `*.key`,
79
- `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `id_rsa`-style keys, `.npmrc`, `.netrc`, `.pypirc`). Remove them or pack a clean
80
- copy. A member named twice in `fabric.package.json` is refused too. A folder swapped for a link while it is read is refused as a
80
+ `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `id_rsa`-style keys, `.npmrc`, `.netrc`, `.pypirc`). Pack a clean copy (above);
81
+ remove in place only litter (`__pycache__`, `.DS_Store`, `*.pyc`) and a credential file the instance keeps elsewhere, never
82
+ `.git`: a folder packed after deleting it would carry what `.gitignore` kept out, an instance folder among it (FAA-190). A member named twice in `fabric.package.json` is refused too. A folder swapped for a link while it is read is refused as a
81
83
  symlink. The Python kit reads each folder through an open handle, so a swap cannot redirect it. The Node kit has no
82
84
  such call: it checks each folder before listing it, after each file it reads and after listing it (FAA-91), and does not
83
85
  see a swap made and undone between two of those checks. Pack from a folder only you can write to.
@@ -118,7 +118,7 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
118
118
  DEC-0122 refuses the member. The kits build it with `a2a_surface()` / `a2aSurface()` (`fabric_a2a`), read it with
119
119
  `a2a_view` / `a2aView`, and `build_well_known(a2a=...)` / `buildWellKnown({a2a})` refuse one that is not the
120
120
  schema's, passed either way; `agent_card_findings` / `agentCardFindings` say what a public card must not carry
121
- ([surfaces and auth](surfaces-and-auth.md#a2a-for-remote-agents)). The kits do not serve a card yet (FAA-10).
121
+ ([surfaces and auth](surfaces-and-auth.md#a2a-for-remote-agents)). Since 0.83.0 `fabric_a2a_server` / `fabric-a2a-server.mjs` serve the card and the A2A JSON-RPC binding ([A2A](a2a.md), FAA-10).
122
122
  - `extensions` MAY carry the embedded dashboard declaration under
123
123
  `https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1` (`fabric-dashboard/0.1`,
124
124
  ED-9): `minWidth` 360…1280 (required), `appearance` ⊆ `scheme`, `theme`, `themes`, `kit`
@@ -230,17 +230,36 @@ and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's toke
230
230
  on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
231
231
  `this-account` refuses, `unknown` included.
232
232
 
233
- macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too and was not measured) only its own sockets: sysctl lists this
233
+ macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python, measured; a Homebrew Node is ad-hoc-signed too) only its own sockets: sysctl lists this
234
234
  process alone and netstat prints nothing. The Python calibration therefore also asks for a child process's listener,
235
- and an empty netstat is `unknown` with reason `table-restricted` (never another account's). A caller that started the
236
- server itself may vouch with that pid: `lsof -p <pid>` asks the process, which the restriction leaves readable, and a
237
- listener of this account's pid on the port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node
238
- `checkedSocket({vouchPid})`). The launchd installers vouch with their own job's pid; elsewhere a restricted table
239
- refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
240
- whole table (FAA-267). A vouch is a weaker proof than the table's: it shows the job's pid listens on the port (`127.0.0.1` or the
241
- IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDaemon's), not that it accepted this connection, so a more specific bind by root over a wildcard job would pass;
242
- the pin's ruling on vouching is DEC-0155 RT-3 (the contract's CT-261), which vouches only for a listen on exactly
243
- `127.0.0.1`: the kit's IPv4 wildcard vouch for a LaunchAgent departs from it until a later release (filed by FAA-258).
235
+ and a restricted table is `unknown` with a reason beginning `table-restricted` (never another account's): DEC-0155 RT-1
236
+ reads so a netstat that prints nothing or only its header, one that fails or exits non-zero, a table holding no row of
237
+ another uid (sysctl) or of another process (netstat), and a macOS table without the reader's own connection (FAA-312;
238
+ Linux and Windows keep `no-row`). Under it `listener(port)` reads this account's listener as its own only on exactly
239
+ `127.0.0.1` (RT-4). `fabric_peer_owner.py listener <port>` and `fabric-peer-owner.mjs listener <port>` print the kind
240
+ (`--json` the whole owner; FAA-315).
241
+
242
+ Under a restricted table the kit asks this account's own processes, which the restriction leaves readable
243
+ (`fabric_socket_proof`, beside `fabric_peer_owner` in every kit install; FAA-292, FAA-272). A server end is this
244
+ account's when one of its processes holds the very socket that accepted the connection (DEC-0155 RT-2, the
245
+ accepted-socket proof; read for up to 0.5 s until the server has called `accept()`), and a listener when one of its
246
+ processes listens on exactly `127.0.0.1:<port>`. Python reads them through libproc (`proc_listpids`, `PROC_PIDLISTFDS`,
247
+ `PROC_PIDFDSOCKETINFO`, Project Observatory's `peer_owner` 1.3.1 reader, calibrated on a pair of its own) and through
248
+ `lsof` when libproc is unavailable; Node through `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`, the exact
249
+ address pair and the numeric uid. The proof is "a process of this uid holds the descriptor": a socket another account
250
+ accepted and passed with SCM_RIGHTS to one of this account's processes reads as this account's, which `expect_pid`
251
+ closes; one socket several of its processes hold is proved with no pid (`several-processes`), which skips an
252
+ `expect_pid` check, as a Linux row does. It never answers another account's, root's or nobody's, a set-uid reader gets
253
+ none, and a project that vendors `fabric_peer_owner` alone answers as before. After it, a caller that started the
254
+ server itself may vouch with that pid: `lsof -p <pid>` asks the process, and a listener of this account's pid on the
255
+ port counts as this account's (`CheckedHTTPConnection(vouch_pid=...)`, Node `checkedSocket({vouchPid})`), taken only
256
+ for a connection that reached `127.0.0.1` (one a caller spelled `::1` or `localhost` that reached `::1` is not; FAA-273);
257
+ the launchd installers vouch with their own job's pid. The Node layout probe (`check_dashboard_layout.mjs`) on such a
258
+ host reads this account's dashboard listener through the own-listener proof (`lsof -u`, FAA-272). What neither proves refuses (the probe, the hub client and `fabric_http`
259
+ included, FAA-272), and Apple's `/usr/bin/python3` sees the whole table (FAA-267). A vouch is weaker than RT-2: it
260
+ shows the pid listens on exactly `127.0.0.1` on the port, not that it accepted this connection. Since FAA-312 every
261
+ vouch follows DEC-0155 RT-3: a wildcard or `::1` listener is never vouched for (`lsof` prints an IPv6-only wildcard as
262
+ `*:<port>` too), and a reader whose real and effective uid differ vouches for nothing for its own account.
244
263
 
245
264
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
246
265
  a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
@@ -17,7 +17,9 @@ DEC-0019, at the commit this skill pins. This page is the how-to.
17
17
 
18
18
  It is, when it runs outside the operator's computer and the operator wants it in Fabric beside
19
19
  the local services: a hosted dashboard, an agent on a server, a bot's web console. It keeps the
20
- same objects (well-known document, events feed, login code) at the same paths.
20
+ same objects (well-known document, events feed, login code) at the same paths. An uptime A agent (one that answers
21
+ while the owner's computer is off, creating-fabric-agents' grill) is one from its first release: its keys are the
22
+ platform's secrets, set from Observatory or the person's secret manager, never Observatory's local vault at run time.
21
23
 
22
24
  ## The descriptor
23
25
 
@@ -73,6 +75,13 @@ watch the gate fail.
73
75
  time (`tokenMatches`). A service registered from more than one host holds one token per host
74
76
  ([Registered from several hosts](#registered-from-several-hosts)).
75
77
  - **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
78
+ - **The data folder on a disk that does not persist**: set `FABRIC_DATA_HOME` to an absolute folder on the platform's
79
+ durable volume, and `service_dirs` / `serviceDirs` put the data folder there (`<home>/<id>`, an instance
80
+ `<home>/<id>.<instance>`); logs and cache stay on the local disk. With no durable volume, keep the data in a
81
+ durable store (a database, an object store) and nothing of value in the folder. A relative value is refused.
82
+ - **A slow resolver**: `http.server.HTTPServer` resolves its host's FQDN between `bind()` and `listen()`, so on a host
83
+ with a slow or missing resolver the port is bound and connects time out; serve with the kit's server, never a bare
84
+ `HTTPServer`.
76
85
  - **Codes and sessions**: `MemoryCodeStore`: a restart forgets every outstanding code, so none
77
86
  can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
78
87
  server keeps each session's last use and logout). The host signs in again on the page's `401`.
@@ -6,6 +6,7 @@
6
6
  - [CLI for people and scripts](#cli-for-people-and-scripts)
7
7
  - [A2A for remote agents](#a2a-for-remote-agents)
8
8
  - [Behind tailscale serve](#behind-tailscale-serve)
9
+ - [A groups file at every door](#a-groups-file-at-every-door)
9
10
  - [Token](#token)
10
11
  - [Dashboard session](#dashboard-session)
11
12
  - [Headers every response carries](#headers-every-response-carries)
@@ -124,6 +125,75 @@ A tagged node (a server, not a person) gets no identity header from serve and is
124
125
  machine caller uses the estate's delegation tokens instead. The serve listener is an outward surface: watch it like
125
126
  the A2A tunnel above.
126
127
 
128
+ ### A groups file at every door
129
+
130
+ `fabric_tailnet_identity.py` / `fabric-tailnet-identity.mjs` (FAA-324) are the simpler sibling for a service whose
131
+ every door (its dashboard, its MCP endpoint, its own API) sits behind `tailscale serve` on one loopback listener, with
132
+ access kept in a file the organization writes rather than a generated perimeter and personal keys.
133
+
134
+ - **The header and where it is believed.** Serve sets `Tailscale-User-Login` and removes any copy a client sent (also
135
+ the `_` spelling); it sets none for a tagged node or for Funnel traffic, which carries `Tailscale-Funnel-Request`
136
+ instead ([Tailscale: identity headers](https://tailscale.com/kb/1312/serve)). The module believes the header only
137
+ when the service listens on a loopback address and the request's peer is loopback; a service bound to `0.0.0.0` or
138
+ a LAN address, or a request from another peer, is refused, since there the header comes from the network. Another
139
+ process of the machine can still reach the loopback port: the kit's loopback server refuses other OS accounts
140
+ (FAA-227), and a listener of serve's own (above) closes the rest where that matters.
141
+ - **The groups file.** JSON the organization writes, at a path the service takes from its own setting (an
142
+ environment variable or a config key it names), checked against `tailnet-groups.schema.json` beside the module:
143
+
144
+ ```json
145
+ {"schema": "tailnet-groups/1",
146
+ "about": "Who may open example-agent's doors.",
147
+ "groups": {"ops": ["ana@example.com", "bo@example.com"], "viewers": ["cy@example.com"]},
148
+ "rules": [{"group": "ops", "door": "dashboard", "capabilities": "*"},
149
+ {"group": "ops", "door": "mcp", "capabilities": ["cve.scan", "vitals.read"]},
150
+ {"group": "ops", "door": "service", "capabilities": "*"},
151
+ {"group": "viewers", "door": "dashboard", "capabilities": ["vitals"]}]}
152
+ ```
153
+
154
+ A door is `dashboard`, `mcp` or `service`; a capability is the name the service asks for there (a page, a tool, an
155
+ action), or `"*"` for all of them. The module also reads the agents base's `door-groups/1` file, written once for
156
+ every service of a machine, for the service it is given (`TailnetIdentity(path, service="example-agent")`, Node
157
+ `{service}`):
158
+
159
+ ```json
160
+ {"format": "door-groups/1", "version": 3, "generated_at": "2026-11-12T22:00:00Z",
161
+ "groups": {"ops": {"people": ["ana@example.com"], "doors": {"example-agent": ["dashboard", "mcp"]}},
162
+ "callers": {"people": ["dee@example.org"], "doors": {"example-agent": ["vitals.read"]}}},
163
+ "retired": {"old-ops": {"at": "2026-11-01", "why": "merged into ops"}}}
164
+ ```
165
+
166
+ For that service `*` opens every door, `dashboard` the dashboard, `mcp` every MCP tool, and a tool name that tool at
167
+ the MCP door and at the service's own door; a group with no entry for the service, and a retired group, open
168
+ nothing, and without a service every door stays closed (`groups-invalid` at `service`). Logins are compared as ASCII names lowercased whole after trimming spaces and
169
+ tabs, with no other folding; a non-ASCII login (serve RFC 2047-encodes one) is refused as malformed.
170
+ - **Wiring.** Make one `TailnetIdentity(path)` at start and ask it at each door: in Python
171
+ `decision = identity.decide_request(self, door="mcp", capability=tool)` in an `http.server` handler, then
172
+ `send_refusal(self, decision)` when `decision["allowed"]` is false; in Node
173
+ `identity.decideRequest(req, {door, capability})` and `sendRefusal(res, decision)`. Behind the kit's
174
+ `loopbackServer` gate the request's own server never listened, so pass `bound: gate.address().address` (Python:
175
+ `bound=` when the handler's server is not the listening one); an unknown bound address is refused. The decision
176
+ carries `login`, `name` (from `Tailscale-User-Name`, for display only) and `groups`.
177
+ - **Reload and failure.** The file is looked at on every request (one `stat`; `recheck_seconds` / `recheckSeconds`
178
+ makes it at most that often) and read again when its inode, size, mtime or ctime change, with no restart. While it
179
+ is missing, unreadable or invalid, every door answers `groups-unreadable` or `groups-invalid`; one line per change
180
+ goes to the log (stderr by default), naming the field and never the file's content.
181
+
182
+ | Code | When |
183
+ |---|---|
184
+ | `bind-not-loopback` | the service does not listen on a loopback address, or the address is unknown |
185
+ | `not-loopback` | the request's peer is not loopback: it did not come through the local serve |
186
+ | `groups-unreadable`, `groups-invalid` | the groups file cannot be read, or breaks its schema; every door is shut |
187
+ | `funnel-request` | the request came through Funnel from the public internet |
188
+ | `no-login` | no `Tailscale-User-Login`, or an empty one |
189
+ | `tagged-node` | no login, and serve's `X-Forwarded-For` is a tailnet address: a node without a user |
190
+ | `login-ambiguous`, `login-malformed` | two login headers, or one that is no login name |
191
+ | `unknown-login` | the login is in no group |
192
+ | `not-allowed` | no group of the login has a rule for this door and capability |
193
+
194
+ Every refusal is 403 with `{"error": {"code", "message"}}`, the message a fixed sentence; `tagged-node` and `no-login`
195
+ differ only in their label, since a direct local call can send any `X-Forwarded-For`.
196
+
127
197
  ## Token
128
198
 
129
199
  - Created once with `ensure_token` at first start, after the instance lock.
@@ -28,6 +28,24 @@ CREDENTIAL_PAIRS = {
28
28
  'session': {'token', 'id'},
29
29
  'code': {'login', 'auth', 'access', 'session'}}
30
30
  JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
+ URL_BASE64_RUN = re.compile(r'[A-Za-z0-9_-]*')
32
+
33
+
34
+ def has_jwt(text):
35
+ # Whether JWT_SHAPE matches anywhere, read in one pass (the contract's hasJwt, FAA-313): the expression backtracks
36
+ # from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one run shares that run's end, so only its
37
+ # first is tried. The kit's fabric_secrets.has_jwt reads the same way; this file stands alone, so it has its own.
38
+ at = text.find('eyJ')
39
+ while at >= 0:
40
+ one = URL_BASE64_RUN.match(text, at + 3).end()
41
+ if one - at - 3 >= 2 and text[one:one + 1] == '.':
42
+ two = URL_BASE64_RUN.match(text, one + 1).end()
43
+ if two - one - 1 >= 2 and text[two:two + 1] == '.':
44
+ return True
45
+ at = text.find('eyJ', one)
46
+ return False
47
+
48
+
31
49
  # The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key with its
32
50
  # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
33
51
  # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
@@ -58,7 +76,7 @@ def credential_name(name):
58
76
  def secret_value(value):
59
77
  long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
60
78
  for run in LONG_RUN.findall(value))
61
- return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
79
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret)
62
80
 
63
81
 
64
82
  def carries_credential(text):
@@ -825,13 +825,15 @@ class Probe:
825
825
  out = subprocess.run(["launchctl", "print", "%s/%s" % (domain, life.get("label"))], capture_output=True, text=True)
826
826
  match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
827
827
  served = ((self.wk or {}).get("process") or {}).get("pid")
828
+ # a LaunchDaemon's leftover LaunchAgent runs in its UserName's gui/<uid> (FAA-317)
829
+ second = other_domain_copy(life.get("label"), domain, plist.get("UserName")) if out.returncode == 0 and match else None
828
830
  if out.returncode != 0:
829
831
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
830
832
  elif not match:
831
833
  self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
832
- elif other_domain_copy(life.get("label"), domain): # loaded in both domains: two copies (FAA-286 item 6)
834
+ elif second: # loaded in both domains: two copies (FAA-286 item 6)
833
835
  self.add("lifecycle.one-copy", "FAIL", "launchd job %s is loaded in %s and in %s: two copies; uninstall one"
834
- % (life.get("label"), domain, other_domain_copy(life.get("label"), domain)))
836
+ % (life.get("label"), domain, second))
835
837
  else:
836
838
  same = int(match.group(1)) == served
837
839
  self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """The A2A rules of the probe (FAA-10): what a service that declares `surfaces.a2a` (DEC-0122) serves, read from the
3
+ outside. `a2a_rules(wk, origin, ...)` returns (rule, verdict, evidence) rows, verdicts PASS, FAIL or NOT_RUN:
4
+
5
+ - `a2a.surface`: `surfaces.a2a` is the schema's (`fabric_a2a.a2a_view`); NOT_RUN when the service declares none;
6
+ - `a2a.card`: the card at `surfaces.a2a.path` answers 200 with a JSON object `agent_card_findings` finds nothing in;
7
+ - `a2a.interface`: the card names an A2A 1.0 JSON-RPC interface on the service origin (local) or on https (remote);
8
+ - `a2a.task-not-found`: `GetTask` of an id nobody made answers JSON-RPC -32001 (TaskNotFound);
9
+ - `a2a.cancel-not-found`: `CancelTask` of that id answers -32001 too;
10
+ - `a2a.method-not-found`: an unknown method answers -32601;
11
+ - `a2a.task-states`: with `probe_text` (a message the service names as safe to send), `SendMessage` answers a message,
12
+ or a task whose state is an A2A 1.0 state that `GetTask` reads back and `CancelTask` moves to TASK_STATE_CANCELED
13
+ (or refuses with -32002 when it has already ended); NOT_RUN without one, since a message may start real work.
14
+
15
+ An interface that answers HTTP 401 makes the binding rules NOT_RUN with the reason: it takes its own key, never the
16
+ descriptor's token (DEC-0122 item 3); give the key through `--key-env` under `use_secret.py run`. It is a command of
17
+ its own beside check_service.py, whose `a2a.card` never calls the card's interfaces (D5); the two say one verdict for the
18
+ card: an unreadable `surfaces.a2a`, and a remote card that answers 401 or 403, are NOT_RUN, never a failure (DEC-0122
19
+ item 2). Standard library only.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import secrets
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
32
+ from fabric_a2a import A2A_PROTOCOL_VERSION, a2a_view, agent_card_findings # noqa: E402
33
+ from fabric_http import HttpError, request as http_request # noqa: E402
34
+
35
+ STATES = ("TASK_STATE_SUBMITTED", "TASK_STATE_WORKING", "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED",
36
+ "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_REJECTED", "TASK_STATE_AUTH_REQUIRED")
37
+ Row = Tuple[str, str, str]
38
+ Fetch = Callable[[str, str, Mapping[str, str], Optional[Dict[str, Any]]], Tuple[int, Any]]
39
+
40
+
41
+ def http_fetch(method: str, url: str, headers: Mapping[str, str], body: Optional[Dict[str, Any]]) -> Tuple[int, Any]:
42
+ """One bounded request (10 s, 1 MiB, no retry); the answer's JSON or None."""
43
+ response = http_request(method, url, headers=dict(headers), body=body, deadline_s=10, retries=0, max_bytes=1 << 20)
44
+ try:
45
+ return response.status, response.json()
46
+ except ValueError:
47
+ return response.status, None
48
+
49
+
50
+ def _rpc(fetch: Fetch, url: str, headers: Mapping[str, str], method: str, params: Dict[str, Any]) -> Tuple[int, Any]:
51
+ return fetch("POST", url, dict(headers, **{"A2A-Version": A2A_PROTOCOL_VERSION, "Content-Type": "application/json"}),
52
+ {"jsonrpc": "2.0", "id": secrets.token_hex(4), "method": method, "params": params})
53
+
54
+
55
+ def _code(answer: Any) -> Optional[int]:
56
+ return answer["error"].get("code") if isinstance(answer, dict) and isinstance(answer.get("error"), dict) else None
57
+
58
+
59
+ def a2a_rules(wk: Any, origin: str, *, fetch: Fetch = http_fetch, headers: Optional[Mapping[str, str]] = None,
60
+ remote: bool = False, descriptor_header: str = "Authorization", probe_text: Optional[str] = None) -> List[Row]:
61
+ """The A2A rows for one service read at `origin`; `headers` carries the interface's own key, never the token."""
62
+ rows: List[Row] = []
63
+ view = a2a_view(wk)
64
+ if not view["declared"]:
65
+ return [("a2a.surface", "NOT_RUN", "the service declares no surfaces.a2a")]
66
+ if not view["valid"]: # as check_service_rules.a2a_card_verdict: a host does not know where the card is
67
+ return [("a2a.surface", "NOT_RUN", "surfaces.a2a is not readable (well-known.a2a): "
68
+ + "; ".join(f["message"] for f in view["findings"]))]
69
+ rows.append(("a2a.surface", "PASS", "surfaces.a2a at %s" % view["path"]))
70
+ try:
71
+ status, card = fetch("GET", origin.rstrip("/") + view["path"], {"Accept": "application/json"}, None)
72
+ except (HttpError, OSError) as exc:
73
+ return rows + [("a2a.card", "FAIL", "the card did not answer: %s" % exc)]
74
+ if remote and status in (401, 403):
75
+ return rows + [("a2a.card", "NOT_RUN", "HTTP %d: a card that is not open, gated by the service's own credentials "
76
+ "or an identity proxy; a refusal is not nonconformance (DEC-0122 item 2)" % status)]
77
+ if status != 200 or not isinstance(card, dict):
78
+ return rows + [("a2a.card", "FAIL", "the card answered HTTP %d%s" % (status, "" if isinstance(card, dict) else ", not a JSON object"))]
79
+ findings = agent_card_findings(card, origin=None if remote else origin.rstrip("/"), remote=remote,
80
+ descriptor_header=descriptor_header)
81
+ rows.append(("a2a.card", "FAIL" if findings else "PASS", "; ".join(f["message"] for f in findings) or "%d skills" % len(card.get("skills") or [])))
82
+ url = next((i.get("url") for i in card.get("supportedInterfaces") or [] if isinstance(i, dict)
83
+ and i.get("protocolBinding") == "JSONRPC" and i.get("protocolVersion") == A2A_PROTOCOL_VERSION
84
+ and isinstance(i.get("url"), str)), None)
85
+ if url is None:
86
+ return rows + [("a2a.interface", "FAIL", "the card names no A2A %s JSON-RPC interface" % A2A_PROTOCOL_VERSION)]
87
+ rows.append(("a2a.interface", "PASS", url))
88
+ sent = dict(headers or {})
89
+ nobody = "probe-" + secrets.token_hex(12)
90
+ checks = (("a2a.task-not-found", "GetTask", {"id": nobody}, -32001), ("a2a.cancel-not-found", "CancelTask", {"id": nobody}, -32001),
91
+ ("a2a.method-not-found", "ProbeNoSuchMethod", {}, -32601))
92
+ for rule, method, params, want in checks:
93
+ try:
94
+ status, answer = _rpc(fetch, url, sent, method, params)
95
+ except (HttpError, OSError) as exc:
96
+ rows.append((rule, "FAIL", "%s did not answer: %s" % (method, exc)))
97
+ continue
98
+ if status in (401, 403):
99
+ rows.append((rule, "NOT_RUN", "the interface answered HTTP %d: it takes its own key (DEC-0122 item 3); pass it with --key-env" % status))
100
+ else:
101
+ got = _code(answer)
102
+ rows.append((rule, "PASS" if got == want else "FAIL", "%s answered %s, A2A 1.0 says %d" % (method, got if got is not None else "HTTP %d" % status, want)))
103
+ rows.append(_task_states(fetch, url, sent, probe_text))
104
+ return rows
105
+
106
+
107
+ def _task_states(fetch: Fetch, url: str, headers: Mapping[str, str], probe_text: Optional[str]) -> Row:
108
+ if not probe_text:
109
+ return ("a2a.task-states", "NOT_RUN", "no probe message: a message may start real work; pass --probe-text the service names as safe")
110
+ message = {"role": "ROLE_USER", "parts": [{"text": probe_text}], "messageId": "probe-" + secrets.token_hex(8)}
111
+ status, answer = _rpc(fetch, url, headers, "SendMessage", {"message": message})
112
+ result = answer.get("result") if isinstance(answer, dict) else None
113
+ if status != 200 or not isinstance(result, dict) or not ({"task", "message"} & set(result)):
114
+ return ("a2a.task-states", "FAIL", "SendMessage answered neither a task nor a message (HTTP %d, code %s)" % (status, _code(answer)))
115
+ if "message" in result:
116
+ return ("a2a.task-states", "PASS", "SendMessage answered a message with no task")
117
+ task = result["task"]
118
+ if not isinstance(task, dict) or (task.get("status") or {}).get("state") not in STATES:
119
+ return ("a2a.task-states", "FAIL", "the task's state is not an A2A 1.0 TaskState")
120
+ read = _rpc(fetch, url, headers, "GetTask", {"id": task.get("id")})[1]
121
+ if not isinstance(read, dict) or ((read.get("result") or {}).get("status") or {}).get("state") not in STATES:
122
+ return ("a2a.task-states", "FAIL", "GetTask did not read the task back")
123
+ cancelled = _rpc(fetch, url, headers, "CancelTask", {"id": task.get("id")})[1]
124
+ state = (((cancelled or {}).get("result") or {}).get("status") or {}).get("state") if isinstance(cancelled, dict) else None
125
+ if state == "TASK_STATE_CANCELED" or _code(cancelled) == -32002:
126
+ return ("a2a.task-states", "PASS", "SendMessage, GetTask and CancelTask agree")
127
+ return ("a2a.task-states", "FAIL", "CancelTask answered neither TASK_STATE_CANCELED nor -32002")
128
+
129
+
130
+ def main(argv: Optional[List[str]] = None) -> int:
131
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
132
+ parser.add_argument("origin", help="the service origin, http://127.0.0.1:<port> or its https origin")
133
+ parser.add_argument("--remote", action="store_true")
134
+ parser.add_argument("--key-env", help="an environment variable holding the A2A interface's own key (use_secret.py run fills it)")
135
+ parser.add_argument("--header", default="Authorization")
136
+ parser.add_argument("--scheme", default="Bearer")
137
+ parser.add_argument("--probe-text")
138
+ parser.add_argument("--json", action="store_true")
139
+ args = parser.parse_args(argv)
140
+ status, wk = http_fetch("GET", args.origin.rstrip("/") + "/.well-known/fabric-service", {"Accept": "application/json"}, None)
141
+ headers: Dict[str, str] = {}
142
+ if args.key_env:
143
+ value = os.environ.get(args.key_env)
144
+ if not value:
145
+ print("%s is empty: run under use_secret.py run" % args.key_env, file=sys.stderr)
146
+ return 2
147
+ headers[args.header] = "%s %s" % (args.scheme, value) if args.scheme else value
148
+ rows = a2a_rules(wk if status == 200 else None, args.origin, headers=headers, remote=args.remote, probe_text=args.probe_text)
149
+ if args.json:
150
+ print(json.dumps([{"rule": r, "verdict": v, "evidence": e} for r, v, e in rows], indent=1))
151
+ else:
152
+ for r, v, e in rows:
153
+ print("%-8s %-22s %s" % (v, r, e))
154
+ return 1 if any(v == "FAIL" for _, v, _ in rows) else 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())