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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -125,6 +125,7 @@ action it also carries the operator's decision, [Actions](#actions)), act on eve
125
125
  (`jobs.quotaExhausted(id, message, {retryable, resetsAt})`, `jobCallerAction`, `intentIdempotencyKey`). The guidance
126
126
  for an agent's own design is in `creating-fabric-agents/references/resilience-and-autonomy.md`.
127
127
 
128
+ <a id="a-nodes-call-queue"></a>
128
129
  **A node's call queue (DEC-0104).** A provider that queues refuses a start past its queue with `queue-full`: an `isError`
129
130
  result with one text item whose JSON is `{error: {code: "queue-full", message, data: {retryable, retryAfterSeconds}}}`,
130
131
  never `structuredContent`; the caller starts again after `retryAfterSeconds` under the same key. Read that refusal
@@ -132,9 +133,58 @@ from the start's own `isError` result, before any answer reaches `job_caller_act
132
133
  job, and `queue-full` never ends one, so they answer a job it ended `invalid` (`QUEUE_FULL` names the code, and the
133
134
  kit's job store refuses to end a job with it). The kit has no reader for the refusal yet (FAA-103). Through Fabric's hub
134
135
  a caller today sees `product-error` instead, kept under the key for 24 hours, until the hub relays `queue-full`
135
- (Fabric CO-376). The kit does not queue: it declares no load extension (`extensions/load/0.1`) and serves no
136
- `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above. A node that will queue needs
137
- `jobCallerKey`, the contract's `agent-network.json` vectors and the load report first (FAA-103).
136
+ (Fabric CO-376). The kit's job store does not queue by itself: it declares no load extension (`extensions/load/0.1`)
137
+ and serves no `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above.
138
+
139
+ A node that queues takes the contract's reference functions from the kit instead of copying them (since 0.80.0,
140
+ FAA-289): `fabric_load.py` and `fabric-load.mjs`, exported by both facades, held to the contract's
141
+ `fixtures/vectors/agent-network.json` and its `job-queue`, `service-load` and `service-well-known-load` fixtures at the
142
+ pin (vendored as `test/fixtures/contract/load-vectors.json` by `make_load_vectors.mjs`):
143
+
144
+ | Python | Node | What |
145
+ |---|---|---|
146
+ | `job_caller_key(accepted, credential)` | `jobCallerKey` | the caller's opaque key: an accepted delegation's `principal`, `caller` and a partner organization ending the chain, else the credential; `None` / `undefined` for a refused or unreadable call |
147
+ | `next_queued_job(queue, running, limits)` | `nextQueuedJob` | the id to start next per (agentRef, context): nothing at `concurrency`, a caller at `perCaller` waits, the caller running fewest first, then the oldest arrival, ties by id |
148
+ | `admit_job(queue, running, job, limits, retry_after_seconds)` | `admitJob` | `{admit: True}` when the job would start at once or fewer than `queueMax` wait, else the `queue-full` error with `retryAfterSeconds` (1 to 3600, else 60) |
149
+ | `queue_status_message(position, length)` | `queueStatusMessage` | a waiting job's `statusMessage`, `queued <position> of <length>` |
150
+ | `read_queue_position(status_message)` | `readQueuePosition` | `{position, length}` from that message, or none |
151
+ | `job_queue_rules(value)` | `jobQueueRules` | FAC-SEM-131 on `{queue, running, limits, started}` |
152
+ | `load_report_rules(report)` | `loadReportRules` | FAC-SEM-132 on a `service-load/1` report: windows one `windowSeconds` apart, none after `generatedAt`, one row per context, p50 at most p95, a run time only where a job ended |
153
+ | `load_report_problems(report)` | `loadReportProblems` | the report against `service-load.schema.json` (no caller, no job id, counts and durations only) |
154
+ | `load_view(well_known)`, `load_extension_rules(well_known)` | `loadView`, `loadExtensionRules` | the declaration as a reader reads it, and FAC-SEM-133: an invalid one is treated as not declared and reported |
155
+ | `load_extension(path, window_seconds, limits)` | `loadExtension({path, windowSeconds, limits})` | the `extensions` entry a service declares; raises `ServiceError` on one a reader would hide |
156
+
157
+ Queue times are UTC instants (`T`, `Z`, at most three fraction digits); report times are RFC 3339 read strictly. A
158
+ node keys every per-caller rule with `job_caller_key`, counts every started job that has not ended (`input_required`
159
+ and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
160
+ and `fabric.job.list` are still the node's own (FAA-103).
161
+
162
+ ### Work held by the service's own limits
163
+
164
+ A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
165
+ says so, so a hold is never seen only by a caller who waits (FAA-298). `fabric_holds` (Node `fabric-holds.mjs`) does
166
+ the three parts:
167
+
168
+ - **On the job.** `hold_job(jobs, job_id, reason, since, until, message)` (Node `holdJob`) pauses the job with the
169
+ statusMessage `waiting_message` writes: `quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`,
170
+ `reason` one of `daily-quota`, `caller-share`, `concurrency`, `money`. The pinned `interop-job` schema is closed
171
+ (`additionalProperties: false`), so `waiting {reason, since, until, message}` is carried in `statusMessage`, the
172
+ contract's pause grammar, never as a field of the job: every caller already reads it as wait (until `until` when
173
+ given), and `read_waiting(answer)` (Node `readWaiting`) gives the four parts back. A job waiting its normal turn in
174
+ the call queue keeps `queued <position> of <length>`; a hold is the service's own limit.
175
+ - **One push per hold.** `HoldBook(path, events=log)` (Node `new HoldBook(file, {events})`) opens a hold per reason and
176
+ caller: `hold(caller=, work=, reason=, until=, job_id=)` returns `{hold, statusMessage, push}`, and only the call that
177
+ opens the hold pushes: one `limit.held` event, level `notice`, `notify: true`, naming the caller, the work, the reason
178
+ and when it frees by itself, and asking whether to raise the limit. More work held by the same limit for the same
179
+ caller joins the hold silently. A hold closes by `release`, when its `until` passes, or by a lift that covers it;
180
+ the next hold pushes again. Names that read as a credential are refused.
181
+ - **The operator's answer as a scoped lift.** `lift(reason=, decision=, caller=, job_id=, jobs= | usd= | limit=,
182
+ until=)` records the operator's words (`fabric_actions.operator_decision("limit.lift", words)`) with its scope (this
183
+ order, one caller, or every caller) and its size: a number of jobs, an amount of money, or a raised limit for a window
184
+ (100 a day for today: `limit=100, until=<midnight>`), and writes `limit.lifted`. `admit(reason=, caller=, job_id=,
185
+ cost_usd=)` spends a jobs or money lift (the narrowest scope first) and answers whether the work may pass the limit;
186
+ pass its answer to `jobs.create(..., limit_holds=not admitted)` for a repeated key. `limit(reason=, base=, caller=)`
187
+ is the limit in force. A lift falls back by itself when its window ends or its amount is spent.
138
188
 
139
189
  <a id="actions"></a>
140
190
 
@@ -389,5 +439,6 @@ your own record, keep it (OA-6).
389
439
  | `interop.events-trace` | every event with a trace carries both `traceId` and `spanId`, well formed | no events page was read |
390
440
  | `interop.action-tools` | every action the well-known `extensions/actions/0.1` declares names a tool `tools/list` serves (DEC-0121 AP-1); one it does not serve is a `FAIL`, and so is a local service that declares actions with no MCP surface (beyond FAC-SEM-142, which reads only a present list) | no declaration, an unreadable one (`well-known.actions` reports it), no listing was read, or a remote service with no MCP surface (its twin may be on A2A or behind a gateway) |
391
441
  | `channel-agent.declaration` | a channel agent's declaration (DEC-0150) passes the contract's FAC-SEM-162 to 165 at the pin, read from `provider.extensions` of the manifest the descriptor names, else from `extensions` of the well-known answer (CA-1); a `FAIL` on the obligations of a declaration the reader still reads (FAC-SEM-164) or a string shaped like a credential (FAC-SEM-165, not a declaration too large to read for credentials) in the read declaration or in any copy beside it, the first five findings shown as code and path, never a declared value | nothing declared, the named manifest unreadable (`interop.manifest-link` fails it), or a key read as not declared (a manifest's own `extensions`, a capability's, another version's key, a well-known copy beside a manifest, a form the schema or FAC-SEM-163 refuses), each named in the evidence, never a failure while producers move to this revision unless a copy holds a string shaped like a credential (that fails in any copy the probe reads: a manifest's own `extensions`, a capability's, the well-known copy, CA-5); no line when the well-known answer is not read; credentials are read as the contract reads them, within its reading budget and under the `0.1` key only (a key of another version is named, not read); a well-known answer carrying the declaration at 75 % of the 2 MiB cap or over is named, never a failure (until the contract's CT-223) |
442
+ | `limits.holds` | a service that declares limits (a load declaration's `limits`, the usage report's `budget` or `budgets`) holds work through `fabric_holds` (`HoldBook`, `waiting_message`, `hold_job`) in a tracked code file (`.py`, `.mjs`, `.js`, `.ts`) of its checkout outside the kit's copy, a vendored `check_service_rules.py` and the tests (FAA-298) | no limits declared, or no checkout found (`--repo` names it), git missing or out of time; and, since 0.81.0, a service that declares limits and holds none through the kit: a migration note naming `fabric_holds`, never a `FAIL`, since services built before the module could not have adopted it |
392
443
 
393
444
  `python3 scripts/check_service.py <id>` prints them with the rest; `--json` for a machine.
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.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` |
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` |
63
63
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
64
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
@@ -78,10 +78,11 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
78
78
  | L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
79
79
  | L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
80
80
  | L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
81
+ | 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)) |
81
82
  | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
82
83
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
83
84
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
84
- | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
85
+ | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document, the loopback HTTP server that refuses another OS account's peer before reading it (`LoopbackHTTPServer`, Node `loopbackServer`; FAA-227) |
85
86
  | L3 | `fabric_context` | one instance per context (FAA-125, N-11): a context's instance, folders, descriptor, lock and marker; not in the facade, installed with `--module fabric_context` ([contexts](estate-record.md#one-instance-per-context-the-helper)) |
86
87
  | L3 | `fabric_auth` | the token, the network guard, login codes |
87
88
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
@@ -91,6 +92,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
91
92
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
92
93
  | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92), a job's approval step under the mandate (`job_step`, C3.3) |
93
94
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
95
+ | L3 | `fabric_holds` | work a service holds by its own limits (FAA-298): the held job's `statusMessage` in the pause grammar (`waiting_message`, `read_waiting`, `hold_job`), one push per hold and the operator's scoped lift (`HoldBook`: `hold`, `release`, `lift`, `admit`, `limit`) ([interop](interop.md#work-held-by-the-services-own-limits)) |
94
96
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
95
97
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
96
98
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
@@ -105,7 +107,8 @@ Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service
105
107
  `check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
106
108
  `service-channel-agent-extension.schema.json` it reads at run time), the sample
107
109
  (`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
108
- the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md)).
110
+ the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md), [a service that is only a
111
+ dashboard](../recipes/dashboard-only/README.md), [a key-delivery door](../recipes/key-delivery-door/README.md)).
109
112
 
110
113
  ## What 0.39 code keeps
111
114
 
@@ -45,7 +45,8 @@ Starting, stopping and restarting go through the supervisor and nothing else:
45
45
  atomic, mode 0600) and the uninstaller removes it; a running service never writes its own, except an app
46
46
  ([below](#an-apps-own-descriptor-and-the-node)). It names the supervisor and its file (`label` and `plist`, `unit`,
47
47
  or `task`; none for `node` and `person`), the `origin` `http://127.0.0.1:<port>` (a port is a claim: an installer
48
- refuses a port another instance declares), the service's directories, and commands as argument arrays that never
48
+ refuses a port another instance declares, and one another OS account listens on, read with the kit's
49
+ `fabric_peer_owner`, FAA-225; the recorded port per account and its claim file wait on contract DEC-0160), the service's directories, and commands as argument arrays that never
49
50
  carry a credential. Only `doctor` and `update` are commands a host may run.
50
51
 
51
52
  **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
@@ -144,27 +145,34 @@ it at boot, before any login, as the account its `UserName` names.
144
145
  install requires (without it the job would run as root, which every client refuses); the file goes in
145
146
  `/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
146
147
  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
+ path, except that an install refuses a pair launchd would contradict: `domain="system"` with a plist in a
149
+ `LaunchAgents` folder (launchd also loads that into `gui/<uid>` at login) and `domain="gui"` with a plist in
150
+ `/Library/LaunchDaemons` (launchd loads it at boot as root). A domain name given as the plist path (`"system"`,
151
+ `"gui"`) is refused, never read as a relative path (FAA-286). Everything else is a LaunchAgent's.
148
152
  `UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
149
153
  - **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
150
154
  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
155
  working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
152
156
  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,
157
+ as one first, so launchd never holds both: the install refuses a plist of the same label left in the `UserName`'s
158
+ `~/Library/LaunchAgents`, and the probe's `lifecycle.one-copy` fails a label loaded in both domains (FAA-286). The descriptor's `lifecycle.plist` names the path exactly,
154
159
  `/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
155
160
  - **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
156
161
  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
162
  personal and an organization account); an install refuses, before writing anything, a label another plist holds
158
163
  (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
164
  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.
165
+ which would stop that plist's job, so pass it the label the plist at its path names; in either domain an uninstall
166
+ refuses a plist at its path that names another label, before its `bootout` (FAA-286).
161
167
  - **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
162
168
  anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
163
169
  `launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
164
170
  does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
165
171
  again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
166
172
  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
173
+ host without root reports it as the operator's. Its commands name absolute paths: `~` would be root's home, and the
174
+ probe's `lifecycle.plist` fails a LaunchDaemon command that starts with `~`, as it fails a LaunchDaemon plist without
175
+ `UserName` (FAA-286). A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
168
176
  `bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
169
177
  `kickstart -k system/<label>`; a host or broker without root reports the job read-only.
170
178
  - **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
@@ -176,23 +184,32 @@ it at boot, before any login, as the account its `UserName` names.
176
184
  that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
177
185
  an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
178
186
  `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
187
+ pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (DEC-0155 RT-3). A `ProgramArguments` wrapper that
180
188
  starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
181
189
  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
190
+ and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. A
191
+ bootstrap that fails all five tries removes the plist the call wrote, and names one that was there before, which
192
+ would load at the next boot (FAA-286). A plist that does not parse is refused as such before anything is written,
193
+ never as a missing `UserName`; the Node installer refuses a binary plist and reads only the root dict's keys. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
183
194
  in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
184
195
  it. A descriptor field for the domain is the contract's CT-234, open.
185
196
  - **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
197
+ account's and nothing it answers as the service's; the pin's RT-3 (DEC-0155) vouches only for the caller's own uid. The
187
198
  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
199
  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
200
+ first proof is the RT-3 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
190
201
  principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
191
202
  text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
192
203
  Every other client keeps HA-2 as written.
193
204
  - **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
194
205
  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).
206
+ for a system job; the lifecycle broker is still asked (FAA-287). A product with its own launchd reader (a status
207
+ page's `launchd_pid`, an installer of its own in another language) that asks only `gui/<uid>` reports a LaunchDaemon
208
+ as not loaded: move that reader to the plist's domain before the product moves to the system domain (FAA-286).
209
+ - **Off macOS the twins differ, on purpose.** Python's `launchd_domain()` answers `gui/<uid>` on Linux (it refuses only
210
+ Windows), so the kit's launchd tests run on Linux CI against a fake `launchctl`; Node's `launchdDomain()` refuses off
211
+ `darwin` unless a test passes `platform: 'darwin'`. Neither runs launchd off macOS: install with
212
+ `systemd_install` / `task_install` there (FAA-286 item 5, recorded rather than aligned).
196
213
 
197
214
  ## One copy under a supervisor
198
215
 
@@ -205,6 +222,10 @@ if the holder leaves, and exits 75 only when the wait runs out: a few starts an
205
222
  360. It touches nothing while it waits. Two installs sharing one data directory are still the
206
223
  defect to remove; the back-off only stops the loop from costing the machine.
207
224
 
225
+ A context refusal (`fabric_context`, exit 78: no record, a revoked grant, a folder of another context) is the same
226
+ loop under `KeepAlive`, so a supervised copy waits the same back-off, holding nothing and with the lock released,
227
+ before it exits 78 (FAA-279); started by hand it exits 78 at once.
228
+
208
229
  The marker (contract DEC-0057, CT-01) is the variable `FABRIC_SERVICE_SUPERVISOR=<manager>` or
209
230
  the argument `--fabric-supervisor <manager>` (also `--fabric-supervisor=<manager>`), after the
210
231
  executable and before any `--`; `<manager>` is `launchd`, `systemd`, `task-scheduler` or `node`
@@ -323,7 +344,7 @@ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.se
323
344
 
324
345
  | Verb | Command |
325
346
  |---|---|
326
- | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
347
+ | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls the well-known answer as `launchd_install` does until this account's listener answers (another account's is refused, DEC-0154 HA-2; FAA-253); Node's `systemdInstall` then returns a promise |
327
348
  | stop, keep off | `systemctl --user disable --now <unit>` |
328
349
  | uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
329
350
 
@@ -340,7 +361,7 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
340
361
 
341
362
  | Verb | Command |
342
363
  |---|---|
343
- | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
364
+ | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls as `launchd_install` does and returns the answer (FAA-253); Node's `taskInstall` then returns a promise |
344
365
  | stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
345
366
  | uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
346
367
 
@@ -31,4 +31,5 @@ Order matters: each step leaves the service answering.
31
31
  | Data, logs or caches inside the repository | the OS data, log and cache directories; the old tree read-only for one release |
32
32
  | `KeepAlive {SuccessfulExit:false}`, a plist pointing at a checkout or a worktree | a plist generated by `launchd_plist`, pointing at a release |
33
33
  | A port another service already uses by default | the port claim at install; move the newcomer |
34
+ | A port held by socket activation (launchd `Sockets`, a systemd `.socket` unit) or by a root proxy in front of the service | the service binds `127.0.0.1:<port>` itself as its own account; launchd's or root's socket reads as `system` and the hosts' and the probe's `listener.owner` refuse it (DEC-0154 HA-2) |
34
35
  | An event log in a private shape | the events view over it, with one sentence per event |
@@ -42,6 +42,16 @@ python3 scripts/fabric_package.py pack <folder> # a dry run: the packa
42
42
  python3 scripts/fabric_package.py pack <folder> --apply # writes fabric.package.json when nothing is wrong
43
43
  ```
44
44
 
45
+ When the folder carries the agent's source manifest, `fabric.source.json` (contract DEC-0149, `fabric-source/1`), `pack`
46
+ writes `secrets[]` from its `secret` slots (name, purpose and `optional`) and `state[]` from its `store` slots' paths,
47
+ says so in its notes, and lists the manifest in `files[]` like any other file; `pack`, `sign` and `verify` refuse a
48
+ package whose `secrets[]` or `state[]` differ from the slots (FAC-SEM-160). A hand-written `secrets[]` naming a secret
49
+ the manifest has no slot for is never dropped: `pack` refuses (rule `source`, `/secrets`) and names each one, so add
50
+ it as a `secret` slot (or take it out) and pack again. An author's `state: []` is a copy packed
51
+ without state and is kept. A manifest that is no `fabric-source/1` object is refused. The manifest's own rules and the
52
+ tree check under it are the source check's (`references/source-and-instance.md` of adapting-projects-to-fabric, *The
53
+ source manifest*).
54
+
45
55
  Run `adapting-projects-to-fabric/scripts/fabric_source_check.py check <folder> --every-file` on the folder you pack first (a floor under your own
46
56
  review, not a secret scanner): a package carries
47
57
  the source, and the instance (the owner's paths, keys, strings) stays out of it (`references/source-and-instance.md` of
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `18f8d48762edc5bfb1433657cbfe1c16196df1a6`
3
+ Pinned to `fabric-agent-contract` commit `93e7db164c7829e2783b4b7705c4113bb5201131`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -224,7 +224,8 @@ well-known `process.pid` is never proof (`docs/specification/service.md#ha-2` at
224
224
  The kit's module is `fabric_peer_owner` / `fabric-peer-owner.mjs` (layer L0, [kit modules](kit-modules.md)): `listener(port)`,
225
225
  `server_end(sock)` / `serverEnd(socket)`, `peer`, and a connection checked before a byte is written
226
226
  (`CheckedHTTPConnection`; Node `checkedSocket`, `CheckedAgent`). Python reads macOS through `sysctl
227
- net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`), Linux through `/proc/net/tcp`
227
+ net.inet.tcp.pcblist_n` (calibrated on a connected pair of its own, else `netstat -anv`; the pinned HA-2 table names
228
+ `netstat -anv` and `pcblist64`, an older record layout of the same kernel table), Linux through `/proc/net/tcp`
228
229
  and `/proc/net/tcp6`, Windows through `GetExtendedTcpTable` and the owner's token SID; Node reads `netstat -anv` and `ps`
229
230
  on macOS, the same `/proc` files on Linux, and `netstat -ano` with the owner SID from PowerShell `GetOwnerSid` against `whoami /user` on Windows (SIDs, never names). Fail closed: everything but
230
231
  `this-account` refuses, `unknown` included.
@@ -238,17 +239,34 @@ listener of this account's pid on the port counts as this account's (`CheckedHTT
238
239
  refuses (the probe, the hub client and `fabric_http` included, FAA-272), and Apple's `/usr/bin/python3` sees the
239
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
240
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;
241
- the contract's ruling on vouching is CT-261.
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).
242
244
 
243
245
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
244
- a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
245
- `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
246
+ a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
247
+ `data.reason` and sentence the owner's reason: `table-restricted`, then not retryable, or the owner's kind or `no-row`, FAA-272),
248
+ `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user` with the same `reason`, never retried),
246
249
  `check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
247
250
  is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
248
251
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
249
252
  twin reads the pid from `netstat` and the uid from `ps` a few milliseconds apart, so a pid reused in between would be
250
- misread; the Python twin's `sysctl` reader takes both from one record. A service's own loopback server refusing another account's peer (HA-3) is FAA-227, and the port
251
- claim file (HA-1) waits on the contract's CT-228.
253
+ misread; the Python twin's `sysctl` reader takes both from one record. The server side (HA-2, HA-3; FAA-227): the kit's `LoopbackHTTPServer` and Node's `loopbackServer(httpServer)` tell
254
+ each accepted connection's peer (`peer`) before a byte of the request is read, in the connection's own thread or
255
+ while the socket is still paused. `peer_check` / `peerCheck` (`PEER_CHECKS`): `refuse`, the default, closes a peer of
256
+ another OS account unread and serves the rest; `strict` also refuses an owner nobody can tell (a restricted table
257
+ included) and a system account other than root (uid 0, which can read every account's files anyway and installs a
258
+ LaunchDaemon; a Windows service account is refused); `off` reads no table. The check applies to loopback peers
259
+ (127.0.0.0/8, `::1`, either IPv4-mapped): HA-2 is about another account of this computer, so a peer at any other
260
+ address (a service bound to `0.0.0.0`) is served under `refuse` and `strict` alike without a table read, and its
261
+ owner is none (`peer_owner()` None, `connectionOwner` null); such a peer is never T0 or T1. Use `strict` for a dashboard, an MCP
262
+ surface or a token-free door on a computer with several counted accounts, where the interpreter sees the whole
263
+ table (Apple's `/usr/bin/python3`, not an ad-hoc-signed one on macOS 27.2). A refusal writes one line on stderr
264
+ (kind, uid, pid, never the peer's bytes); a handler finds its peer's owner with `self.server.peer_owner()` or
265
+ `connectionOwner(req.socket)`. The cost is one table read per connection, not per request: a few milliseconds from
266
+ `sysctl` or `/proc` in-process; Node on macOS and Windows runs `netstat` and `ps` or PowerShell for each, tens to
267
+ hundreds of milliseconds, so a Node service there that answers the well-known read within 100 ms keeps its
268
+ connections alive or chooses `off` where one account uses the computer. The port claim file (HA-1) waits on the
269
+ contract's CT-228.
252
270
 
253
271
  ## Semantic rules a host applies
254
272
 
@@ -10,6 +10,7 @@ DEC-0019, at the commit this skill pins. This page is the how-to.
10
10
  - [On the platform](#on-the-platform)
11
11
  - [Checking it](#checking-it)
12
12
  - [What a host does and will not do](#what-a-host-does-and-will-not-do)
13
+ - [Registered from several hosts](#registered-from-several-hosts)
13
14
  - [Rotation](#rotation)
14
15
 
15
16
  ## Is it a remote service?
@@ -68,8 +69,9 @@ watch the gate fail.
68
69
  ## On the platform
69
70
 
70
71
  - **Token**: a random value of at least 16 characters (32 bytes recommended), set as a secret;
71
- the same value is written on the operator's computer by `registerRemote`. Compare in constant
72
- time (`tokenMatches`).
72
+ the same value is written on the registering host by `registerRemote`. Compare in constant
73
+ time (`tokenMatches`). A service registered from more than one host holds one token per host
74
+ ([Registered from several hosts](#registered-from-several-hosts)).
73
75
  - **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
74
76
  - **Codes and sessions**: `MemoryCodeStore`: a restart forgets every outstanding code, so none
75
77
  can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
@@ -122,10 +124,49 @@ The login rules keep every `Set-Cookie` of the answer and judge the session cook
122
124
  | start, stop, restart, update | not offered |
123
125
  | a host older than the remote placement | shows the service as invalid and never contacts it |
124
126
 
127
+ ## Registered from several hosts
128
+
129
+ One cloud service is often registered from more than one host: the operator's two computers, or an organization's
130
+ space beside a personal one (FAA-280). Each registering host gets **its own token**; a token is never copied from one
131
+ host to another, so one host can be revoked or rotated without touching the others.
132
+
133
+ - **On the platform**, keep one secret per host (`FABRIC_HOST_TOKEN_<HOST>`, each at least 16 characters) and accept
134
+ a request whose bearer matches any of them. Compare it with every token, each with `tokenMatches` /
135
+ `token_matches`, with no early return, so the answer's timing does not say which host's token matched:
136
+
137
+ ```python
138
+ def host_of(authorization, tokens): # tokens: {host name: token}; the kit's comparison, once per token
139
+ matched = [host for host, token in sorted(tokens.items()) if fs.token_matches(authorization, token)]
140
+ return matched[0] if matched else None # None: a 401 with an empty body, as with one token
141
+ ```
142
+
143
+ `well_known_allowed` / `wellKnownAllowed` take one token: call it once per token the same way, or gate the route on
144
+ `host_of(...) is not None`.
145
+ - **On each host**, run `registerRemote` / `register_remote` with that host's token only. The descriptor is the same
146
+ on every host but its `tokenFile`, which holds that host's value and nothing else (0600, never printed).
147
+ - **Sign-in**: `LoginCodes` binds a session to one token's revision. Give each host its own `LoginCodes` with
148
+ `token=` that host's token and its own store (`MemoryCodeStore`, or a durable row per host); the session key may be
149
+ shared. Issue the login code from the instance whose token the bearer matched; redeem a code and check a session by
150
+ trying each instance in turn (a code another instance issued is not in its store, and a session cookie carries
151
+ another token's revision, so neither changes anything there). Removing or rotating one host's token then ends that
152
+ host's sessions only. One `LoginCodes` whose `token=` is a callable returning every token joined also works, and
153
+ signs every host out when any one rotates.
154
+ - **The probe**: `check_service.py <id>` run on a host sends that host's token, so `well-known.requires-token` passes
155
+ on each; the request without a token, or with a value no host holds, still gets the `401` with an empty body.
156
+ - **Revoking a host**: delete its secret on the platform and restart; that host then sees `401` and shows the service
157
+ `down`, never `foreign`. Remove its descriptor there with `remove_descriptor` / `removeDescriptor`.
158
+ - **Adding a host**: create its secret, restart, then `registerRemote` on the new host. The other hosts' tokens and
159
+ sessions do not change.
160
+
161
+ A token per host still gives each host the descriptor token's whole authority over the service. A remote surface no
162
+ host token reaches (callers with their own delegation, the page opened only with Fabric Dashboards' one-time code)
163
+ is the "local and cloud" form, FAA-204, which waits on the contract recording that form.
164
+
125
165
  ## Rotation
126
166
 
127
167
  Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
128
- operator's computer with it; the old token stops working the moment the platform restarts with the
168
+ registering host with it (on a service registered from several hosts, only that host's token,
169
+ [above](#registered-from-several-hosts)); the old token stops working the moment the platform restarts with the
129
170
  new value, and so does every session begun under it when `LoginCodes` has `token=`. The re-run keeps the
130
171
  installed `source` when it passes none (one the kit refuses, edited by hand, is dropped with the warning); pass
131
172
  `runner`, `peer`, `summary`, `doctor` and `installedBy` / `installed_by` again, since a re-run writes only what it is
@@ -127,6 +127,25 @@ async function serviceTarget(spec, dir) {
127
127
  return { url, declared, name: `${id}.${instance}` };
128
128
  }
129
129
 
130
+ // DEC-0154 PL-3 and HA-2 (FAA-260 items 2 and 14): a raw loopback address opens in the browser only while its port's
131
+ // listener is this OS account's, read from the socket table just before the page opens. The browser connects on its
132
+ // own, so this is the listener's owner, not that connection's: a listener swapped in between stays a residue
133
+ // (SECURITY.md). A copy of this file alone cannot tell the owner and opens no loopback page.
134
+ async function loopbackRefusal(target) {
135
+ let url;
136
+ try { url = new URL(target); } catch { return null; }
137
+ if (url.protocol !== 'http:') return null;
138
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
139
+ if (!(host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1'
140
+ || host === '0.0.0.0' || /^::ffff:(127\.|7f)/.test(host))) return null;
141
+ const po = await import('./fabric-peer-owner.mjs').catch(() => null);
142
+ if (!po) return 'fabric-peer-owner.mjs is not beside this file: the loopback listener\'s owner cannot be told, so the page is not opened (DEC-0154 HA-2)';
143
+ const port = Number(url.port || 80);
144
+ const who = await po.listener(port);
145
+ if (who.kind === po.THIS_ACCOUNT) return null;
146
+ return `foreign-user: the listener on port ${port} reads as ${who.kind}${who.reason ? ` (${who.reason})` : ''}, so the page is not opened (DEC-0154 PL-3, HA-2)`;
147
+ }
148
+
130
149
  // Runs inside the page. Everything it reports is measured from the rendered layout.
131
150
  function measure([floor, cap]) {
132
151
  const de = document.documentElement;
@@ -319,6 +338,8 @@ async function main(argv) {
319
338
  try {
320
339
  const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
321
340
  const page = await context.newPage();
341
+ const refused = await loopbackRefusal(target);
342
+ if (refused) return notRun(refused);
322
343
  const response = await page.goto(target, { waitUntil: 'load' });
323
344
  if (response && !response.ok()) {
324
345
  return notRun(`the page answered HTTP ${response.status()}`);
@@ -28,8 +28,14 @@ CREDENTIAL_PAIRS = {
28
28
  'session': {'token', 'id'},
29
29
  'code': {'login', 'auth', 'access', 'session'}}
30
30
  JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
- SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
- r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
31
+ # 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
+ # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
33
+ # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
34
+ SECRET_VALUE = re.compile(r'(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"\']|\\u[0-9A-Fa-f]{4}'
35
+ r'|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m'
36
+ r'|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)'
37
+ r'(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})'
38
+ r'|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))', re.S)
33
39
  LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
40
 
35
41
 
@@ -115,6 +121,11 @@ def check(data):
115
121
  require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
116
122
  fallback = data.get('fallback', 'if_absent')
117
123
  require(fallback in ('if_absent', 'never'), 'invalid_fallback')
124
+ # DEC-0154 PL-3 and HA-2 (FAA-251): who listens on http_url's port, as the host read it from the socket table
125
+ # (fabric_peer_owner's kinds, or check_service.py's foreign-user). A browser opens a raw loopback address only
126
+ # after that listener is this OS account's; absent, it is unknown.
127
+ owner = data.get('listener_owner', 'unknown')
128
+ require(owner in ('this-account', 'another-account', 'system', 'unknown', 'none', 'foreign-user'), 'invalid_listener_owner')
118
129
  kind = action.get('kind')
119
130
  if kind == 'remote_open':
120
131
  require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
@@ -128,17 +139,26 @@ def check(data):
128
139
  require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
129
140
  require(viewer == target, 'wrong_viewer_device')
130
141
  require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
142
+ require(owner == 'this-account', 'listener_not_this_account')
131
143
  else:
132
144
  raise ValueError('unsupported_dashboard_action')
133
145
  return {'ok': True, 'kind': kind}
134
146
 
135
147
 
148
+ def read_input(limit=65536):
149
+ # The handoff's bytes, decoded as UTF-8 with a byte order mark dropped: Windows before Python 3.15 decodes a text
150
+ # stdin with its code page, which misreads a Cyrillic handoff (FAA-210). A stream without bytes is read as text.
151
+ stream = getattr(sys.stdin, 'buffer', None)
152
+ raw = stream.read(limit + 1) if stream is not None else sys.stdin.read(limit + 1)
153
+ require(len(raw) <= limit, 'input_too_large')
154
+ return raw.decode('utf-8-sig') if isinstance(raw, bytes) else raw.lstrip('\ufeff')
155
+
156
+
136
157
  def main():
137
158
  try:
138
- raw = sys.stdin.read(65537)
139
- require(len(raw) <= 65536, 'input_too_large')
140
- result = check(json.loads(raw))
141
- except (ValueError, TypeError, KeyError, AttributeError):
159
+ result = check(json.loads(read_input()))
160
+ # RecursionError: Python 3.9 to 3.13 raise it on a deeply nested document under the cap
161
+ except (ValueError, TypeError, KeyError, AttributeError, RecursionError):
142
162
  # Fixed reasons only: never echo a submitted URL, token or JSON blob.
143
163
  result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
144
164
  print(json.dumps(result))