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

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 (83) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/bin/fabric-agent-adapter.js +26 -4
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +41 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +5 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +35 -2
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +20 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +340 -24
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -1
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  69. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -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
@@ -205,6 +206,10 @@ if the holder leaves, and exits 75 only when the wait runs out: a few starts an
205
206
  360. It touches nothing while it waits. Two installs sharing one data directory are still the
206
207
  defect to remove; the back-off only stops the loop from costing the machine.
207
208
 
209
+ A context refusal (`fabric_context`, exit 78: no record, a revoked grant, a folder of another context) is the same
210
+ loop under `KeepAlive`, so a supervised copy waits the same back-off, holding nothing and with the lock released,
211
+ before it exits 78 (FAA-279); started by hand it exits 78 at once.
212
+
208
213
  The marker (contract DEC-0057, CT-01) is the variable `FABRIC_SERVICE_SUPERVISOR=<manager>` or
209
214
  the argument `--fabric-supervisor <manager>` (also `--fabric-supervisor=<manager>`), after the
210
215
  executable and before any `--`; `<manager>` is `launchd`, `systemd`, `task-scheduler` or `node`
@@ -323,7 +328,7 @@ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.se
323
328
 
324
329
  | Verb | Command |
325
330
  |---|---|
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` |
331
+ | 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
332
  | stop, keep off | `systemctl --user disable --now <unit>` |
328
333
  | uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
329
334
 
@@ -340,7 +345,7 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
340
345
 
341
346
  | Verb | Command |
342
347
  |---|---|
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` |
348
+ | 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
349
  | stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
345
350
  | uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
346
351
 
@@ -241,8 +241,9 @@ IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDae
241
241
  the contract's ruling on vouching is CT-261.
242
242
 
243
243
  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),
244
+ a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
245
+ `data.reason` and sentence the owner's reason: `table-restricted`, then not retryable, or the owner's kind or `no-row`, FAA-272),
246
+ `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user` with the same `reason`, never retried),
246
247
  `check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
247
248
  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
249
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
@@ -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
@@ -133,12 +133,20 @@ def check(data):
133
133
  return {'ok': True, 'kind': kind}
134
134
 
135
135
 
136
+ def read_input(limit=65536):
137
+ # The handoff's bytes, decoded as UTF-8 with a byte order mark dropped: Windows before Python 3.15 decodes a text
138
+ # stdin with its code page, which misreads a Cyrillic handoff (FAA-210). A stream without bytes is read as text.
139
+ stream = getattr(sys.stdin, 'buffer', None)
140
+ raw = stream.read(limit + 1) if stream is not None else sys.stdin.read(limit + 1)
141
+ require(len(raw) <= limit, 'input_too_large')
142
+ return raw.decode('utf-8-sig') if isinstance(raw, bytes) else raw.lstrip('\ufeff')
143
+
144
+
136
145
  def main():
137
146
  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):
147
+ result = check(json.loads(read_input()))
148
+ # RecursionError: Python 3.9 to 3.13 raise it on a deeply nested document under the cap
149
+ except (ValueError, TypeError, KeyError, AttributeError, RecursionError):
142
150
  # Fixed reasons only: never echo a submitted URL, token or JSON blob.
143
151
  result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
144
152
  print(json.dumps(result))
@@ -673,12 +673,14 @@ class Probe:
673
673
  self.events_trace_rule(self.events)
674
674
 
675
675
  def channel_agent_rule(self) -> None:
676
- """DEC-0150 (FAA-197): the channel agent's declaration, read from the manifest the descriptor names, else from the
677
- well-known answer (CA-1); one read as not declared is NOT_RUN with its evidence, never a FAIL (D3), except a string
678
- shaped like a credential in it or in a copy the probe reads beside it (CA-5)."""
676
+ """DEC-0150 (FAA-197): the declaration, from the manifest the descriptor names, else the well-known answer (CA-1); one
677
+ read as not declared is NOT_RUN, never a FAIL (D3), except a credential's shape in it or a copy beside it (CA-5)."""
679
678
  manifest, _, where = self.load_manifest()
680
- self.add("channel-agent.declaration", *cac.declaration_verdict(
681
- manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None)))
679
+ try: # a copy of the probe without its schema file is NOT_RUN, and the later rules run (FAA-250)
680
+ verdict = cac.declaration_verdict(manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
681
+ except (OSError, ValueError) as exc:
682
+ verdict = ("NOT_RUN", "the probe cannot read %s beside it (%s)" % (cac.cas.SCHEMA_FILE.name, type(exc).__name__))
683
+ self.add("channel-agent.declaration", *verdict)
682
684
 
683
685
  def configuration_rule(self) -> None:
684
686
  """DEC-0046: a well-known `configuration` is {slots, resolved} as the schema types it; absent is NOT_RUN."""
@@ -18,7 +18,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
21
- from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, _url_parts, raw_values, secret_argument, secret_url, secret_value # noqa: E402
21
+ from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
22
+ from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
22
23
  import check_service_channel_agent_schema as cas # noqa: E402
23
24
 
24
25
  KEY = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1"
@@ -62,6 +63,9 @@ _IGNORABLE = ((0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1
62
63
  (0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
63
64
  (0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF))
64
65
  _FILLERS = frozenset("\u115f\u1160\u3164\uffa0\u2800\ufffc")
66
+ #: Format characters (\p{Cf}) assigned after Unicode 13, which Python 3.9's unicodedata reads as unassigned (FAA-250):
67
+ #: U+0890..0891 (Unicode 14) and U+13439..1343F (Unicode 15); the white-space and control categories gained none since.
68
+ _LATER_FORMATS = ((0x0890, 0x0891), (0x13439, 0x1343F))
65
69
 
66
70
 
67
71
  def utf16(text: str) -> str:
@@ -75,7 +79,7 @@ def utf16(text: str) -> str:
75
79
  def _blank_char(char: str) -> bool:
76
80
  code = ord(char)
77
81
  return (char in JS_SPACE or char in _FILLERS or unicodedata.category(char) in ("Cf", "Cc", "Zs", "Zl", "Zp")
78
- or any(low <= code <= high for low, high in _IGNORABLE))
82
+ or any(low <= code <= high for low, high in _IGNORABLE + _LATER_FORMATS))
79
83
 
80
84
 
81
85
  def blank(text: str) -> bool:
@@ -397,7 +401,7 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
397
401
  return None
398
402
  if host and not _host_ok(host, special):
399
403
  return None
400
- _, query, fragment = _url_parts(text)
404
+ _, query, fragment = _whatwg_url_parts(text)
401
405
  has_query = "?" in rest.partition("#")[0]
402
406
  has_fragment = "#" in rest
403
407
  return ("?" + query if has_query and query else "", "#" + fragment if has_fragment and fragment else "")
@@ -823,8 +827,9 @@ def declaration_verdict(manifest: Optional[Dict[str, Any]], manifest_named: bool
823
827
  where = "the manifest's provider.extensions" if site == "manifest" else "the well-known answer's extensions"
824
828
  shapes = _credential_shapes(others + copy)
825
829
  if shapes: # a credential's shape in a copy no reader reads still sits in a document the service publishes (CA-5)
826
- return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s" % (
827
- len(shapes), _shown(shapes), tail)
830
+ also = ("; on the read declaration itself, %d finding(s): %s" % (len(own), _shown(own))) if own else ""
831
+ return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s%s" % (
832
+ len(shapes), _shown(shapes), also, tail)
828
833
  if declared and own:
829
834
  if not channel_agent_view(doc, site)["declared"] and not _credential_shapes(own):
830
835
  # D3: what the contract's reader reads as not declared (an older form, a repeat, a dangling reference) is
@@ -3,6 +3,7 @@ network guard's verdict, the board responder's fixture conversation, a session c
3
3
  whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
4
4
  opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
5
5
  Standard library only, Python 3.9+."""
6
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
6
7
  from __future__ import annotations
7
8
 
8
9
  import email.utils
@@ -104,6 +104,8 @@ export class Responder extends SlotKeeper {
104
104
  this.state = 'idle';
105
105
  this.waitUntil = 0;
106
106
  this.stopping = false;
107
+ /** Whether shutdown found work to drain, published before it waits; null before shutdown starts (FAA-186). */
108
+ this.draining = null;
107
109
  this.claimKeys = {};
108
110
  this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
109
111
  this.said = {};
@@ -939,6 +941,7 @@ export class Responder extends SlotKeeper {
939
941
  async shutdown(drain = DRAIN_SECONDS) {
940
942
  this.stopping = true;
941
943
  const work = this.current;
944
+ this.draining = Boolean(work); // what this shutdown waits for (FAA-186)
942
945
  if (work && !work.done) {
943
946
  await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
944
947
  if (!work.done) work.ctx.stopped = true;
@@ -254,6 +254,7 @@ export class SlotKeeper {
254
254
  if (!(e instanceof BoardError)) throw e;
255
255
  this.retryLater(capability, entries, e);
256
256
  if (SESSION_CODES.has(e.code)) throw e;
257
+ if (e.code === 'idempotency_window_expired') await this.status(); // the epoch moved: read it before the retry (FAA-186)
257
258
  }
258
259
  }
259
260
  }
@@ -6,7 +6,7 @@
6
6
  // An organization's instance whose context the record does not pin (`organizations`, DEC-0127) still starts: the helper
7
7
  // checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
8
8
  // hubKeyCertified reads a context's hub keys only while the record pins that organization (DL-14a, DEC-0128), so the
9
- // service passes `estateContext: context` to its delegation gate. A refusal at start would stop every organization
9
+ // service passes contextGateOptions(info, record) (its context and its pin's issuer, FAA-150) to its delegation gate. A refusal at start would stop every organization
10
10
  // instance on today's records, which pin none until every reader is pinned at or after DEC-0127.
11
11
  import crypto from 'node:crypto';
12
12
  import fs from 'node:fs';
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import { INSTANCE, ServiceError } from './fabric-base.mjs';
15
15
  import { descriptorPath } from './fabric-descriptor.mjs';
16
16
  import { parseContext } from './fabric-estate.mjs';
17
- import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
17
+ import { InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, holdSingleInstance, lockWaitBudgetSeconds, supervisorMarker } from './fabric-lock.mjs';
18
18
  import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
19
19
  import { instanceShareGrant } from './fabric-share.mjs';
20
20
  import { clockMs } from './fabric-time.mjs';
@@ -112,9 +112,29 @@ function markerMatches(file, expected) {
112
112
  return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
113
113
  }
114
114
 
115
+ /**
116
+ * What delegationGate takes from this instance's context (FAA-150): `estateContext`, and for an organization whose
117
+ * context `record` pins, `issuer`: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance refuses a
118
+ * token the personal hub key signed. `info` is contextInstance's (or holdContextInstance's) answer; spread it after the
119
+ * service's own estate issuer: `delegationGate({ ..., issuer: estateIssuer, ...contextGateOptions(info, record) })`.
120
+ * Throws ContextRefused (`context-invalid`) for an answer with no context.
121
+ */
122
+ export function contextGateOptions(info, record) {
123
+ const context = info !== null && typeof info === 'object' ? info.context : undefined;
124
+ const parsed = parseContext(context);
125
+ if (!parsed) throw new ContextRefused('context-invalid');
126
+ const options = { estateContext: context };
127
+ const pins = record !== null && typeof record === 'object' && !Array.isArray(record) ? record.organizations : undefined;
128
+ if (parsed.kind === 'org' && Array.isArray(pins)) {
129
+ const pin = pins.find((p) => p !== null && typeof p === 'object' && p.org === context && typeof p.iss === 'string');
130
+ if (pin) options.issuer = pin.iss;
131
+ }
132
+ return options;
133
+ }
134
+
115
135
  /**
116
136
  * Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
117
- * naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
137
+ * naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then holdSingleInstance on the instance's data folder (its markers,
118
138
  * its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
119
139
  * another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
120
140
  * instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
@@ -123,8 +143,20 @@ export async function holdContextInstance(id, context, {
123
143
  record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
124
144
  waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
125
145
  } = {}) {
126
- const refuse = (code) => {
127
- process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.\n`);
146
+ // FAA-279: under a supervisor that restarts on exit an exit 78 at once is a respawn every few seconds, forever; a
147
+ // supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing.
148
+ const refuse = async (code) => {
149
+ const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
150
+ const wait = Math.max(0, waitSeconds ?? lockWaitBudgetSeconds(marker));
151
+ const tail = wait > 0 ? ` Waiting ${Math.round(wait)} s before exiting, so its supervisor does not restart it every few seconds.` : '';
152
+ process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.${tail}\n`);
153
+ const now = clock ?? (() => performance.now() / 1000);
154
+ const pause = sleep ?? ((seconds) => new Promise((resolve) => { setTimeout(resolve, seconds * 1000); }));
155
+ const deadline = now() + wait;
156
+ for (let delay = LOCK_BACKOFF_FIRST_SECONDS, remaining = deadline - now(); remaining > 0; remaining = deadline - now()) {
157
+ await pause(Math.min(delay, remaining));
158
+ delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
159
+ }
128
160
  return exit(EXIT_CONTEXT_REFUSED);
129
161
  };
130
162
  let info;
@@ -1,10 +1,12 @@
1
1
  // How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069), build identity and the well-known document.
2
2
  // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
3
+ import { spawnSync } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
6
7
  import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
8
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
+ import { ANOTHER_ACCOUNT } from './fabric-peer-owner.mjs'; // L0: who listens on a port (DEC-0154 HA-2, FAA-225)
8
10
  import { protocolsFindings } from './fabric-protocols.mjs';
9
11
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
10
12
  import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
@@ -313,9 +315,43 @@ export function sourceRefusal(d, dir = servicesDir()) {
313
315
  return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
314
316
  }
315
317
 
316
- // FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
317
- // (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working.
318
- export function writeDescriptor(d, dir = servicesDir()) {
318
+ // The child that answers portOwner: a plain listen on 127.0.0.1:<port> that succeeds means nothing holds it (the table
319
+ // is not read); one that fails asks fabric-peer-owner's listener. A child, because writeDescriptor is synchronous.
320
+ const PORT_OWNER_CHILD = `
321
+ import net from 'node:net';
322
+ const [port, url] = process.argv.slice(1);
323
+ const say = (value) => process.stdout.write(JSON.stringify(value));
324
+ const server = net.createServer();
325
+ server.once('error', async () => {
326
+ try { const { listener } = await import(url); const who = await listener(Number(port)); say([who.kind, Number.isInteger(who.uid) ? who.uid : null]); }
327
+ catch { say(['unknown', null]); }
328
+ });
329
+ server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
330
+ `;
331
+
332
+ /** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
333
+ * (FAA-225, DEC-0154 HA-2). */
334
+ export function portOwner(port) {
335
+ const done = spawnSync(process.execPath, ['--input-type=module', '-e', PORT_OWNER_CHILD, String(port),
336
+ new URL('./fabric-peer-owner.mjs', import.meta.url).href], { encoding: 'utf8', timeout: 10000 });
337
+ try { return JSON.parse(done.stdout); } catch { return ['unknown', null]; }
338
+ }
339
+
340
+ /** Why an install may not take `port`: another OS account listens on it (FAA-225). This account, the system (launchd
341
+ * holds a socket-activated job's socket), nobody and an unreadable table pass. The recorded port per account and its
342
+ * claim file wait on contract DEC-0160. */
343
+ export function portListenerRefusal(port, reader = portOwner) {
344
+ const found = reader(port);
345
+ if (!Array.isArray(found) || found[0] !== ANOTHER_ACCOUNT) return null;
346
+ const uid = Number.isInteger(found[1]) ? ` (uid ${found[1]})` : '';
347
+ return `Port ${port} is listened on by another OS account${uid}: choose a port no other account holds (DEC-0154, FAA-225).`;
348
+ }
349
+
350
+ /** Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another OS
351
+ * account listens on (FAA-225; `portOwner(port) -> [kind, uid] | null` reads it, the kit's own by default).
352
+ * FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
353
+ * (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working. */
354
+ export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner } = {}) {
319
355
  const problems = validateDescriptor(d);
320
356
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
321
357
  const me = `${d.id}.${d.instance}`;
@@ -334,6 +370,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
334
370
  try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
335
371
  if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
336
372
  }
373
+ const held = port === null ? null : portListenerRefusal(port, reader);
374
+ if (held) throw new ServiceError(held);
337
375
  ensurePrivateDir(dir);
338
376
  const target = descriptorPath(d.id, d.instance, dir);
339
377
  atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
@@ -16,9 +16,11 @@
16
16
  // The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
17
17
  // { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
18
18
  // HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
19
+ //
20
+ // Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
19
21
 
20
22
  import http from 'node:http';
21
- import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
23
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
22
24
 
23
25
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
24
26
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
@@ -27,7 +29,7 @@ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
27
29
 
28
30
  export class HttpError extends Error {
29
31
  constructor(message, { status = null, url = '', attempts = 0, retryAfterS = null, body = new Uint8Array(0),
30
- kind = 'http' } = {}) {
32
+ kind = 'http', reason = null } = {}) {
31
33
  super(message);
32
34
  this.name = 'HttpError';
33
35
  this.status = status;
@@ -36,6 +38,8 @@ export class HttpError extends Error {
36
38
  this.retryAfterS = retryAfterS;
37
39
  this.body = body.slice(0, 2048);
38
40
  this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
41
+ // For foreign-user, why (FAA-272): table-restricted (never clears), the owner's kind, or the check's reason.
42
+ this.reason = reason;
39
43
  }
40
44
  }
41
45
 
@@ -184,7 +188,9 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
184
188
  req.on('error', (err) => {
185
189
  clearTimeout(timer);
186
190
  if (err instanceof ListenerRefused) {
187
- reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user' }));
191
+ const why = String(err.owner?.reason ?? err.owner?.kind ?? err.code ?? 'unknown');
192
+ const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
193
+ reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user', reason }));
188
194
  } else reject(err);
189
195
  });
190
196
  if (body) req.write(body);
@@ -27,7 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
29
29
  import { hubKeyCertified } from './fabric-estate.mjs';
30
- import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
30
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
31
31
 
32
32
  // DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
33
33
  // socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
@@ -53,6 +53,20 @@ export class HubError extends Error {
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
58
+ * state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
59
+ * runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
60
+ * owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
61
+ */
62
+ export function listenerRefusal(e) {
63
+ const kind = e?.owner?.kind ?? null;
64
+ const why = String(e?.owner?.reason ?? kind ?? e?.code ?? 'unknown');
65
+ const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
66
+ const shown = kind && e?.owner?.reason ? `${kind}: ${e.owner.reason}` : String(kind ?? why);
67
+ return new HubError('foreign-user', `The hub's port is not held by this OS account's process (${shown}); nothing was sent.`, reason !== TABLE_RESTRICTED, { reason });
68
+ }
69
+
56
70
  /** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
57
71
  export function fabricRoot(platform = process.platform, env = process.env) {
58
72
  const home = env.HOME || env.USERPROFILE || os.homedir();
@@ -202,7 +216,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
202
216
  });
203
217
  req.on('timeout', () => req.destroy(new Error('timeout')));
204
218
  req.on('error', (e) => reject(e instanceof ListenerRefused
205
- ? new HubError('foreign-user', `The hub's port is not held by this OS account's process (${e.owner?.kind ?? e.code}); nothing was sent.`, true)
219
+ ? listenerRefusal(e)
206
220
  : new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
207
221
  if (payload) req.write(payload);
208
222
  req.end();