@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  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 +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,408 @@
1
+ """Who may call a service: the descriptor token, the network guard,
2
+ the operator's one-time login and its session, remote placement (DEC-0019) and the feed client's token header.
3
+ Part of the service kit (FAA-65, layer L3)."""
4
+ from __future__ import annotations
5
+
6
+ import datetime as _dt
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import os
11
+ import re
12
+ import secrets
13
+ import stat
14
+ import threading
15
+ import time
16
+ import urllib.parse
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, List, Optional, Tuple
19
+
20
+ import sys as _sys # noqa: E402
21
+ from pathlib import Path as _Path # noqa: E402
22
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
23
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
24
+ from fabric_base import (
25
+ PROTOCOL,
26
+ ServiceError,
27
+ WINDOWS,
28
+ now_iso,
29
+ )
30
+ from fabric_platform import (
31
+ _win_token_problem,
32
+ )
33
+ from fabric_paths import (
34
+ atomic_write,
35
+ ensure_private_dir,
36
+ services_dir,
37
+ )
38
+ from fabric_descriptor import (
39
+ validate_descriptor,
40
+ write_descriptor,
41
+ )
42
+
43
+
44
+ LOGIN_CODE_TTL_SECONDS = 120
45
+
46
+
47
+ # DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session.
48
+ OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60
49
+
50
+
51
+ OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60
52
+
53
+
54
+ _CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}\Z")
55
+
56
+
57
+ def ensure_token(path: Path) -> str:
58
+ """Create the service token once (0600, owner only); never rotate silently."""
59
+ path = Path(path)
60
+ if path.exists():
61
+ return read_token(path)
62
+ ensure_private_dir(path.parent)
63
+ token = secrets.token_urlsafe(32)
64
+ atomic_write(path, token.encode(), 0o600)
65
+ return token
66
+
67
+
68
+ def read_token(path: Path) -> str:
69
+ """Refuse a symlink, a file owned by someone else, or one others can read.
70
+
71
+ On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
72
+ windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
73
+ never the contents."""
74
+ path = Path(path)
75
+ info = os.lstat(path)
76
+ if stat.S_ISLNK(info.st_mode):
77
+ raise ServiceError("Token file %s is a symlink; refusing it." % path)
78
+ if WINDOWS:
79
+ problem = _win_token_problem(path, info)
80
+ if problem:
81
+ raise ServiceError(problem)
82
+ else:
83
+ if info.st_uid != os.getuid():
84
+ raise ServiceError("Token file %s belongs to another user." % path)
85
+ if info.st_mode & 0o077:
86
+ raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
87
+ token = path.read_text().strip()
88
+ if len(token) < 16:
89
+ raise ServiceError("Token file %s holds no usable token." % path)
90
+ return token
91
+
92
+
93
+ def token_matches(presented: Optional[str], token: str, scheme: str = "Bearer") -> bool:
94
+ if not presented:
95
+ return False
96
+ value = presented
97
+ if scheme == "Bearer":
98
+ if not presented.startswith("Bearer "):
99
+ return False
100
+ value = presented[len("Bearer "):]
101
+ return hmac.compare_digest(value.strip().encode(), token.encode())
102
+
103
+
104
+ def check_request(port: int, host: Optional[str], origin: Optional[str] = None,
105
+ sec_fetch_site: Optional[str] = None) -> Optional[str]:
106
+ """Return why a request must be refused (403), or None when it may proceed."""
107
+ allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
108
+ if host not in allowed_hosts:
109
+ return "Host %r is not this service." % host
110
+ if origin is not None and origin not in {"http://" + h for h in allowed_hosts}:
111
+ return "Origin %r is not this service." % origin
112
+ if sec_fetch_site == "cross-site":
113
+ return "Cross-site requests are refused."
114
+ return None
115
+
116
+
117
+ def token_header(auth: Any, token: str) -> Tuple[str, str]:
118
+ """The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
119
+ defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
120
+ ``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
121
+ declared = auth if isinstance(auth, dict) else {}
122
+ name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
123
+ scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
124
+ return name, token if scheme == "none" else "Bearer " + token
125
+
126
+
127
+ class LoginCodes:
128
+ """Single-use login codes (<=120 s), recorded as used BEFORE they are honoured, and operator
129
+ sessions with the DEC-0039 lifetime: a session ends 12 h after its last use, 7 days after the code
130
+ was redeemed, on logout, and when the descriptor token rotates. The server enforces all four: the
131
+ cookie carries a signed claim (session id, sign-in time, token revision) and the code store keeps a
132
+ record per session (last use, logout). Rotating the key with revoke_all() ends every session."""
133
+
134
+ def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
135
+ store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None,
136
+ token: "Optional[str | Callable[[], str]]" = None,
137
+ idle_seconds: int = OPERATOR_SESSION_IDLE_SECONDS,
138
+ max_seconds: int = OPERATOR_SESSION_MAX_SECONDS,
139
+ clock: Callable[[], float] = time.time):
140
+ # A local service keeps codes, sessions and the key in files. An online one (DEC-0019) usually has
141
+ # no durable disk: pass state_dir=None with store=MemoryCodeStore() and key= bytes from a platform
142
+ # secret. A memory store forgets codes AND sessions on a restart, so a deploy signs the operator
143
+ # out (the host signs in again on the page's 401); pass a durable store to keep sessions.
144
+ # token= is the descriptor token, or a callable returning the current one: a session begun under
145
+ # one token is refused once it rotates. A service MAY shorten either bound, never lengthen one.
146
+ self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
147
+ self.idle_seconds = _session_bound(idle_seconds, OPERATOR_SESSION_IDLE_SECONDS)
148
+ self.max_seconds = _session_bound(max_seconds, OPERATOR_SESSION_MAX_SECONDS)
149
+ # One lock around every load-modify-save, so a concurrent refresh never saves over a logout or a
150
+ # burned code (a threading server checks sessions on several threads; the instance lock keeps
151
+ # other processes out of the state directory).
152
+ self._lock = threading.RLock()
153
+ self._store = store
154
+ self._fixed_key = key
155
+ self._token = token
156
+ self._clock = clock
157
+ if state_dir is not None:
158
+ self.dir = ensure_private_dir(Path(state_dir))
159
+ self._key_path = self.dir / "session.key"
160
+ self._codes_path = self.dir / "login-codes.json"
161
+ elif store is None or key is None:
162
+ raise ServiceError("LoginCodes without a state directory needs store= and key=.")
163
+ if key is not None and len(key) < 32:
164
+ raise ServiceError("the session key must be at least 32 bytes.")
165
+
166
+ def _key(self) -> bytes:
167
+ if self._fixed_key is not None:
168
+ return bytes(self._fixed_key)
169
+ if not self._key_path.exists():
170
+ atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
171
+ return self._key_path.read_bytes()
172
+
173
+ def _load(self) -> Dict[str, Any]:
174
+ if self._store is not None:
175
+ return self._store.load()
176
+ try:
177
+ return json.loads(self._codes_path.read_text())
178
+ except (OSError, ValueError):
179
+ return {}
180
+
181
+ def _save(self, codes: Dict[str, Any]) -> None:
182
+ horizon = self._clock() - 3600
183
+ codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
184
+ if self._store is not None:
185
+ self._store.save(codes)
186
+ return
187
+ atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
188
+
189
+ def issue(self) -> Dict[str, str]:
190
+ with self._lock:
191
+ code = secrets.token_urlsafe(24)
192
+ expires = self._clock() + self.ttl
193
+ codes = self._load()
194
+ codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
195
+ self._save(codes)
196
+ at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
197
+ return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
198
+
199
+ def redeem(self, code: Optional[str]) -> Optional[str]:
200
+ """Return a session cookie value, or None. The code is burned even when it has expired."""
201
+ with self._lock:
202
+ if not code or not _CODE.match(code):
203
+ return None
204
+ digest = hashlib.sha256(code.encode()).hexdigest()
205
+ codes = self._load()
206
+ entry = codes.get(digest)
207
+ if not entry or entry.get("used"):
208
+ return None
209
+ entry["used"] = True
210
+ self._save(codes)
211
+ now = self._clock()
212
+ if entry.get("expires", 0) < now:
213
+ return None
214
+ session_id = secrets.token_urlsafe(18)
215
+ signed_in = int(now)
216
+ codes[self._record_key(session_id)] = {"expires": signed_in + self.max_seconds,
217
+ "signedInAt": signed_in, "lastUsedAt": now}
218
+ self._save(codes)
219
+ claim = "v2.%s.%d.%s" % (session_id, signed_in, self._token_revision())
220
+ return claim + "." + self._mac(claim)
221
+
222
+ def session_valid(self, cookie_value: Optional[str]) -> bool:
223
+ """True while the session is live; a use within the bounds refreshes the idle bound."""
224
+ with self._lock:
225
+ parsed = self._parse(cookie_value)
226
+ if parsed is None:
227
+ return False
228
+ session_id, signed_in = parsed
229
+ now = self._clock()
230
+ if now >= signed_in + self.max_seconds:
231
+ return False
232
+ codes = self._load()
233
+ record = codes.get(self._record_key(session_id))
234
+ if not record or record.get("signedInAt") != signed_in or record.get("loggedOutAt") is not None:
235
+ return False
236
+ last = float(record.get("lastUsedAt", 0))
237
+ if now >= last + self.idle_seconds:
238
+ return False
239
+ if now - last >= min(60, max(1, self.idle_seconds // 10)):
240
+ record["lastUsedAt"] = now
241
+ self._save(codes)
242
+ return True
243
+
244
+ def logout(self, cookie_value: Optional[str]) -> bool:
245
+ """End this session on the server: every copy of the cookie is refused from now on. Clear the
246
+ browser's copy with expired_session_cookie_header(). False when there was no live record."""
247
+ with self._lock:
248
+ parsed = self._parse(cookie_value)
249
+ if parsed is None:
250
+ return False
251
+ codes = self._load()
252
+ record = codes.get(self._record_key(parsed[0]))
253
+ if not record or record.get("signedInAt") != parsed[1] or record.get("loggedOutAt") is not None:
254
+ return False
255
+ record["loggedOutAt"] = self._clock()
256
+ self._save(codes)
257
+ return True
258
+
259
+ def _parse(self, cookie_value: Optional[str]) -> Optional[Tuple[str, int]]:
260
+ """The signed claim of a cookie under the current key and token, or None."""
261
+ if not isinstance(cookie_value, str) or not cookie_value.isascii():
262
+ return None
263
+ parts = cookie_value.split(".")
264
+ if len(parts) != 5 or parts[0] != "v2" or not parts[1] or not parts[2].isdigit():
265
+ return None
266
+ claim, mac = ".".join(parts[:4]), parts[4]
267
+ if not hmac.compare_digest(mac, self._mac(claim)):
268
+ return None
269
+ if not hmac.compare_digest(parts[3], self._token_revision()):
270
+ return None
271
+ return parts[1], int(parts[2])
272
+
273
+ def _mac(self, claim: str) -> str:
274
+ return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
275
+
276
+ def _token_revision(self) -> str:
277
+ token = self._token() if callable(self._token) else self._token
278
+ if not token:
279
+ return "-"
280
+ return hmac.new(self._key(), b"token-revision\0" + token.encode(), hashlib.sha256).hexdigest()[:16]
281
+
282
+ @staticmethod
283
+ def _record_key(session_id: str) -> str:
284
+ return "s:" + hashlib.sha256(session_id.encode()).hexdigest()
285
+
286
+ def revoke_all(self) -> None:
287
+ if self._fixed_key is not None:
288
+ raise ServiceError("a platform-held session key is rotated on the platform, not here.")
289
+ atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
290
+
291
+
292
+ def _session_bound(value: Any, longest: int) -> int:
293
+ """A session bound in seconds: a finite number, never longer than the contract's (DEC-0039)."""
294
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value in (float("inf"), float("-inf")):
295
+ raise ServiceError("a session bound must be a finite number of seconds.")
296
+ return min(int(value), longest)
297
+
298
+
299
+ class MemoryCodeStore:
300
+ """DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
301
+
302
+ def __init__(self) -> None:
303
+ self._codes: Dict[str, Any] = {}
304
+
305
+ def load(self) -> Dict[str, Any]:
306
+ return json.loads(json.dumps(self._codes))
307
+
308
+ def save(self, codes: Dict[str, Any]) -> None:
309
+ self._codes = json.loads(json.dumps(codes))
310
+
311
+
312
+ SESSION_COOKIE = "fabric_session"
313
+
314
+
315
+ REMOTE_SESSION_COOKIE = "__Host-fabric_session"
316
+
317
+
318
+ def session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
319
+ """The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner."""
320
+ return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
321
+
322
+
323
+ def remote_session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
324
+ """DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
325
+ return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
326
+
327
+
328
+ def expired_session_cookie_header(remote: bool = False) -> str:
329
+ """The header that clears the browser's session cookie, sent with a logout."""
330
+ return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
331
+
332
+
333
+ def _default_port_dropped(scheme: str, value: str) -> str:
334
+ """A host or an origin as a WHATWG URL writes it (finding 15): the scheme's default port (https 443, http 80) left out."""
335
+ default = {"https": ":443", "http": ":80"}.get(scheme.lower())
336
+ return value[:-len(default)] if default and value.endswith(default) else value
337
+
338
+
339
+ def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
340
+ sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
341
+ """DEC-0019: the request guard of an online service; returns the refusal sentence or None.
342
+ ``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
343
+ when the process terminates TLS itself. A default port, written or not, names the same host."""
344
+ own = urllib.parse.urlsplit(origin)
345
+ own_host = _default_port_dropped(own.scheme, own.netloc.lower())
346
+ if _default_port_dropped(own.scheme, str(host or "").lower()) != own_host:
347
+ return "Host %s is not this service." % host
348
+ if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
349
+ return "This service answers over https only."
350
+ if request_origin is not None and _default_port_dropped(own.scheme, str(request_origin)) != "%s://%s" % (own.scheme, own_host):
351
+ return "Origin %s is not this service." % request_origin
352
+ if sec_fetch_site == "cross-site":
353
+ return "Cross-site requests are refused."
354
+ return None
355
+
356
+
357
+ def well_known_allowed(placement: str, authorization: Optional[str], token: str, scheme: str = "Bearer") -> bool:
358
+ """DEC-0019: a local service answers anyone (the loopback guard already ran); a remote one only
359
+ the bearer of the service token. False is a 401 with an EMPTY body."""
360
+ return placement != "remote" or token_matches(authorization, token, scheme)
361
+
362
+
363
+ def remote_token_file(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
364
+ return (directory or services_dir()).parent / "tokens" / ("%s.%s.token" % (service_id, instance))
365
+
366
+
367
+ def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
368
+ summary: Optional[str] = None, doctor: Optional[List[str]] = None,
369
+ directory: Optional[Path] = None, installed_by: Any = None,
370
+ runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
371
+ """DEC-0019, installer side: register an online service on THIS computer — the token file
372
+ (0600, never printed) and the descriptor. The same token is set on the platform as a secret.
373
+
374
+ DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
375
+ register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
376
+ string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
377
+ credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
378
+ if not token or len(token.strip()) < 16:
379
+ raise ServiceError("the service token must be at least 16 characters.")
380
+ root = directory or services_dir()
381
+ token_file = remote_token_file(service_id, instance, root)
382
+ descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
383
+ "placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
384
+ "lifecycle": {"manager": "none"}, "installedAt": now_iso(),
385
+ "installedBy": installed_by if installed_by is not None else
386
+ {"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
387
+ if summary:
388
+ descriptor["summary"] = summary
389
+ if runner is not None:
390
+ descriptor["runner"] = dict(runner)
391
+ if peer is not None:
392
+ descriptor["peer"] = dict(peer)
393
+ if doctor:
394
+ descriptor["commands"] = {"doctor": list(doctor)}
395
+ problems = validate_descriptor(descriptor)
396
+ if problems:
397
+ raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
398
+ ensure_private_dir(token_file.parent)
399
+ atomic_write(token_file, token.strip().encode(), 0o600)
400
+ return write_descriptor(descriptor, root)
401
+
402
+
403
+ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
404
+ for part in (cookie_header or "").split(";"):
405
+ key, _, value = part.strip().partition("=")
406
+ if key == name:
407
+ return value
408
+ return None