@passioncode-ai/fabric-agent-adapter 0.9.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +84 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +57 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +427 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +92 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +761 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +43 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +130 -34
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -279,14 +279,17 @@ class JobStore:
|
|
|
279
279
|
return dict(record["job"])
|
|
280
280
|
|
|
281
281
|
def create(self, capability: str, arguments: Any, traceparent: Optional[str] = None,
|
|
282
|
-
poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
|
|
282
|
+
poll_interval_ms: Optional[int] = None, delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
283
283
|
"""Start a job; returns the handle a job-capable tool puts in structuredContent."""
|
|
284
284
|
job_id = "job_" + secrets.token_hex(12)
|
|
285
285
|
job: Dict[str, Any] = {"id": job_id, "status": "working"}
|
|
286
286
|
if poll_interval_ms:
|
|
287
287
|
job["pollIntervalMs"] = int(poll_interval_ms)
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
private: Dict[str, Any] = {"capability": capability, "input": arguments,
|
|
289
|
+
"traceparent": traceparent if parse_traceparent(traceparent) else None}
|
|
290
|
+
if delegation: # DEC-0039: who started it; only they read, cancel or answer it
|
|
291
|
+
private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
|
|
292
|
+
self._save({"job": job, "private": private})
|
|
290
293
|
return {"job": {"id": job_id, "status": "working"}}
|
|
291
294
|
|
|
292
295
|
def get(self, job_id: str) -> Dict[str, Any]:
|
|
@@ -361,16 +364,19 @@ class JobStore:
|
|
|
361
364
|
class CallContext:
|
|
362
365
|
"""What a tool handler gets: the child span it runs in, and a way to start a job."""
|
|
363
366
|
|
|
364
|
-
def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str
|
|
367
|
+
def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
|
|
368
|
+
delegation: Optional[Dict[str, Any]] = None):
|
|
365
369
|
self.server = server
|
|
366
370
|
self.tool = tool
|
|
367
371
|
self.arguments = arguments
|
|
368
372
|
self.traceparent = traceparent
|
|
373
|
+
# DEC-0039: the accepted delegation (principal, caller, chain, claims) when the server has a gate.
|
|
374
|
+
self.delegation = delegation
|
|
369
375
|
|
|
370
376
|
def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
|
|
371
377
|
if self.server.jobs is None:
|
|
372
378
|
raise InteropError("This server has no job store.")
|
|
373
|
-
handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms)
|
|
379
|
+
handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms, self.delegation)
|
|
374
380
|
self.server.job_started(handle["job"]["id"], self)
|
|
375
381
|
return handle
|
|
376
382
|
|
|
@@ -471,7 +477,12 @@ class McpToolServer:
|
|
|
471
477
|
|
|
472
478
|
def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
|
|
473
479
|
on_job_started: Optional[Callable[[str, CallContext], None]] = None,
|
|
474
|
-
on_input: Optional[OnInput] = None
|
|
480
|
+
on_input: Optional[OnInput] = None,
|
|
481
|
+
authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None):
|
|
482
|
+
# authorize(tool, headers) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
|
|
483
|
+
# a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
|
|
484
|
+
# gated by the job's own capability, and answered only for the principal and caller that started it.
|
|
485
|
+
self._authorize = authorize
|
|
475
486
|
self.info = {"name": name, "version": version}
|
|
476
487
|
self.jobs = jobs
|
|
477
488
|
self.tools: List[Dict[str, Any]] = []
|
|
@@ -540,17 +551,39 @@ class McpToolServer:
|
|
|
540
551
|
name = params.get("name")
|
|
541
552
|
arguments = params.get("arguments") or {}
|
|
542
553
|
if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
|
|
543
|
-
|
|
554
|
+
verdict = None
|
|
555
|
+
if self._authorize is not None:
|
|
556
|
+
job_id = str(arguments.get("id", ""))
|
|
557
|
+
try:
|
|
558
|
+
private = self.jobs.private(job_id)
|
|
559
|
+
except UnknownJob:
|
|
560
|
+
return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(job_id, traceparent=span)}
|
|
561
|
+
verdict = self._authorize(private["capability"], headers)
|
|
562
|
+
owner = private.get("delegation")
|
|
563
|
+
if verdict.get("ok") and owner and (owner.get("principal"), owner.get("caller")) != (verdict.get("principal"), verdict.get("caller")):
|
|
564
|
+
verdict = {"ok": False, "code": "scope-not-granted", "detail": "job-of-another-caller"}
|
|
565
|
+
if not verdict.get("ok"):
|
|
566
|
+
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
567
|
+
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
568
|
+
return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict)}
|
|
544
569
|
handler = self.handlers.get(name)
|
|
545
570
|
if handler is None:
|
|
546
571
|
return self._error(rid, -32602, "Unknown tool: %s." % name)
|
|
572
|
+
delegation = None
|
|
573
|
+
if self._authorize is not None:
|
|
574
|
+
verdict = self._authorize(name, headers)
|
|
575
|
+
if not verdict.get("ok"):
|
|
576
|
+
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
577
|
+
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
578
|
+
delegation = verdict
|
|
547
579
|
try:
|
|
548
|
-
structured = handler(arguments, CallContext(self, name, arguments, span))
|
|
580
|
+
structured = handler(arguments, CallContext(self, name, arguments, span, delegation))
|
|
549
581
|
except InteropError as exc:
|
|
550
582
|
return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
|
|
551
583
|
return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
|
|
552
584
|
|
|
553
|
-
def _job_call(self, name: str, arguments: Dict[str, Any], span: str
|
|
585
|
+
def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
|
|
586
|
+
delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
554
587
|
job_id = str(arguments.get("id", ""))
|
|
555
588
|
try:
|
|
556
589
|
job_span = self.jobs.traceparent(job_id) or span
|
|
@@ -566,7 +599,7 @@ class McpToolServer:
|
|
|
566
599
|
matched = self.jobs.answer(job_id, responses)
|
|
567
600
|
if matched and self._on_input:
|
|
568
601
|
private = self.jobs.private(job_id)
|
|
569
|
-
self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span))
|
|
602
|
+
self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
|
|
570
603
|
return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
|
|
571
604
|
except UnknownJob:
|
|
572
605
|
return unknown_job_result(job_id, traceparent=span)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -63,6 +63,9 @@ DEFAULT_EXIT_TIMEOUT = 15
|
|
|
63
63
|
LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
64
64
|
LOG_BACKUPS = 5
|
|
65
65
|
LOGIN_CODE_TTL_SECONDS = 120
|
|
66
|
+
# DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session.
|
|
67
|
+
OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60
|
|
68
|
+
OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60
|
|
66
69
|
EVENTS_DEFAULT_LIMIT = 50
|
|
67
70
|
EVENTS_MAX_LIMIT = 200
|
|
68
71
|
LEVELS = ("info", "notice", "warning", "error")
|
|
@@ -1203,17 +1206,35 @@ class Drain:
|
|
|
1203
1206
|
# --- operator login ----------------------------------------------------------
|
|
1204
1207
|
|
|
1205
1208
|
class LoginCodes:
|
|
1206
|
-
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured,
|
|
1207
|
-
|
|
1209
|
+
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured, and operator
|
|
1210
|
+
sessions with the DEC-0039 lifetime: a session ends 12 h after its last use, 7 days after the code
|
|
1211
|
+
was redeemed, on logout, and when the descriptor token rotates. The server enforces all four: the
|
|
1212
|
+
cookie carries a signed claim (session id, sign-in time, token revision) and the code store keeps a
|
|
1213
|
+
record per session (last use, logout). Rotating the key with revoke_all() ends every session."""
|
|
1208
1214
|
|
|
1209
1215
|
def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
|
|
1210
|
-
store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1216
|
+
store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None,
|
|
1217
|
+
token: "Optional[str | Callable[[], str]]" = None,
|
|
1218
|
+
idle_seconds: int = OPERATOR_SESSION_IDLE_SECONDS,
|
|
1219
|
+
max_seconds: int = OPERATOR_SESSION_MAX_SECONDS,
|
|
1220
|
+
clock: Callable[[], float] = time.time):
|
|
1221
|
+
# A local service keeps codes, sessions and the key in files. An online one (DEC-0019) usually has
|
|
1222
|
+
# no durable disk: pass state_dir=None with store=MemoryCodeStore() and key= bytes from a platform
|
|
1223
|
+
# secret. A memory store forgets codes AND sessions on a restart, so a deploy signs the operator
|
|
1224
|
+
# out (the host signs in again on the page's 401); pass a durable store to keep sessions.
|
|
1225
|
+
# token= is the descriptor token, or a callable returning the current one: a session begun under
|
|
1226
|
+
# one token is refused once it rotates. A service MAY shorten either bound, never lengthen one.
|
|
1214
1227
|
self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
|
|
1228
|
+
self.idle_seconds = _session_bound(idle_seconds, OPERATOR_SESSION_IDLE_SECONDS)
|
|
1229
|
+
self.max_seconds = _session_bound(max_seconds, OPERATOR_SESSION_MAX_SECONDS)
|
|
1230
|
+
# One lock around every load-modify-save, so a concurrent refresh never saves over a logout or a
|
|
1231
|
+
# burned code (a threading server checks sessions on several threads; the instance lock keeps
|
|
1232
|
+
# other processes out of the state directory).
|
|
1233
|
+
self._lock = threading.RLock()
|
|
1215
1234
|
self._store = store
|
|
1216
1235
|
self._fixed_key = key
|
|
1236
|
+
self._token = token
|
|
1237
|
+
self._clock = clock
|
|
1217
1238
|
if state_dir is not None:
|
|
1218
1239
|
self.dir = ensure_private_dir(Path(state_dir))
|
|
1219
1240
|
self._key_path = self.dir / "session.key"
|
|
@@ -1239,7 +1260,7 @@ class LoginCodes:
|
|
|
1239
1260
|
return {}
|
|
1240
1261
|
|
|
1241
1262
|
def _save(self, codes: Dict[str, Any]) -> None:
|
|
1242
|
-
horizon =
|
|
1263
|
+
horizon = self._clock() - 3600
|
|
1243
1264
|
codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
|
|
1244
1265
|
if self._store is not None:
|
|
1245
1266
|
self._store.save(codes)
|
|
@@ -1247,39 +1268,101 @@ class LoginCodes:
|
|
|
1247
1268
|
atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
|
|
1248
1269
|
|
|
1249
1270
|
def issue(self) -> Dict[str, str]:
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1271
|
+
with self._lock:
|
|
1272
|
+
code = secrets.token_urlsafe(24)
|
|
1273
|
+
expires = self._clock() + self.ttl
|
|
1274
|
+
codes = self._load()
|
|
1275
|
+
codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
|
|
1276
|
+
self._save(codes)
|
|
1277
|
+
at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
|
|
1278
|
+
return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
|
|
1257
1279
|
|
|
1258
1280
|
def redeem(self, code: Optional[str]) -> Optional[str]:
|
|
1259
1281
|
"""Return a session cookie value, or None. The code is burned even when it has expired."""
|
|
1260
|
-
|
|
1282
|
+
with self._lock:
|
|
1283
|
+
if not code or not _CODE.match(code):
|
|
1284
|
+
return None
|
|
1285
|
+
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
1286
|
+
codes = self._load()
|
|
1287
|
+
entry = codes.get(digest)
|
|
1288
|
+
if not entry or entry.get("used"):
|
|
1289
|
+
return None
|
|
1290
|
+
entry["used"] = True
|
|
1291
|
+
self._save(codes)
|
|
1292
|
+
now = self._clock()
|
|
1293
|
+
if entry.get("expires", 0) < now:
|
|
1294
|
+
return None
|
|
1295
|
+
session_id = secrets.token_urlsafe(18)
|
|
1296
|
+
signed_in = int(now)
|
|
1297
|
+
codes[self._record_key(session_id)] = {"expires": signed_in + self.max_seconds,
|
|
1298
|
+
"signedInAt": signed_in, "lastUsedAt": now}
|
|
1299
|
+
self._save(codes)
|
|
1300
|
+
claim = "v2.%s.%d.%s" % (session_id, signed_in, self._token_revision())
|
|
1301
|
+
return claim + "." + self._mac(claim)
|
|
1302
|
+
|
|
1303
|
+
def session_valid(self, cookie_value: Optional[str]) -> bool:
|
|
1304
|
+
"""True while the session is live; a use within the bounds refreshes the idle bound."""
|
|
1305
|
+
with self._lock:
|
|
1306
|
+
parsed = self._parse(cookie_value)
|
|
1307
|
+
if parsed is None:
|
|
1308
|
+
return False
|
|
1309
|
+
session_id, signed_in = parsed
|
|
1310
|
+
now = self._clock()
|
|
1311
|
+
if now >= signed_in + self.max_seconds:
|
|
1312
|
+
return False
|
|
1313
|
+
codes = self._load()
|
|
1314
|
+
record = codes.get(self._record_key(session_id))
|
|
1315
|
+
if not record or record.get("signedInAt") != signed_in or record.get("loggedOutAt") is not None:
|
|
1316
|
+
return False
|
|
1317
|
+
last = float(record.get("lastUsedAt", 0))
|
|
1318
|
+
if now >= last + self.idle_seconds:
|
|
1319
|
+
return False
|
|
1320
|
+
if now - last >= min(60, max(1, self.idle_seconds // 10)):
|
|
1321
|
+
record["lastUsedAt"] = now
|
|
1322
|
+
self._save(codes)
|
|
1323
|
+
return True
|
|
1324
|
+
|
|
1325
|
+
def logout(self, cookie_value: Optional[str]) -> bool:
|
|
1326
|
+
"""End this session on the server: every copy of the cookie is refused from now on. Clear the
|
|
1327
|
+
browser's copy with expired_session_cookie_header(). False when there was no live record."""
|
|
1328
|
+
with self._lock:
|
|
1329
|
+
parsed = self._parse(cookie_value)
|
|
1330
|
+
if parsed is None:
|
|
1331
|
+
return False
|
|
1332
|
+
codes = self._load()
|
|
1333
|
+
record = codes.get(self._record_key(parsed[0]))
|
|
1334
|
+
if not record or record.get("signedInAt") != parsed[1] or record.get("loggedOutAt") is not None:
|
|
1335
|
+
return False
|
|
1336
|
+
record["loggedOutAt"] = self._clock()
|
|
1337
|
+
self._save(codes)
|
|
1338
|
+
return True
|
|
1339
|
+
|
|
1340
|
+
def _parse(self, cookie_value: Optional[str]) -> Optional[Tuple[str, int]]:
|
|
1341
|
+
"""The signed claim of a cookie under the current key and token, or None."""
|
|
1342
|
+
if not isinstance(cookie_value, str) or not cookie_value.isascii():
|
|
1343
|
+
return None
|
|
1344
|
+
parts = cookie_value.split(".")
|
|
1345
|
+
if len(parts) != 5 or parts[0] != "v2" or not parts[1] or not parts[2].isdigit():
|
|
1261
1346
|
return None
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
entry = codes.get(digest)
|
|
1265
|
-
if not entry or entry.get("used"):
|
|
1347
|
+
claim, mac = ".".join(parts[:4]), parts[4]
|
|
1348
|
+
if not hmac.compare_digest(mac, self._mac(claim)):
|
|
1266
1349
|
return None
|
|
1267
|
-
|
|
1268
|
-
self._save(codes)
|
|
1269
|
-
if entry.get("expires", 0) < time.time():
|
|
1350
|
+
if not hmac.compare_digest(parts[3], self._token_revision()):
|
|
1270
1351
|
return None
|
|
1271
|
-
return
|
|
1352
|
+
return parts[1], int(parts[2])
|
|
1272
1353
|
|
|
1273
|
-
def
|
|
1274
|
-
|
|
1275
|
-
return session_id + "." + mac
|
|
1354
|
+
def _mac(self, claim: str) -> str:
|
|
1355
|
+
return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
|
|
1276
1356
|
|
|
1277
|
-
def
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1357
|
+
def _token_revision(self) -> str:
|
|
1358
|
+
token = self._token() if callable(self._token) else self._token
|
|
1359
|
+
if not token:
|
|
1360
|
+
return "-"
|
|
1361
|
+
return hmac.new(self._key(), b"token-revision\0" + token.encode(), hashlib.sha256).hexdigest()[:16]
|
|
1362
|
+
|
|
1363
|
+
@staticmethod
|
|
1364
|
+
def _record_key(session_id: str) -> str:
|
|
1365
|
+
return "s:" + hashlib.sha256(session_id.encode()).hexdigest()
|
|
1283
1366
|
|
|
1284
1367
|
def revoke_all(self) -> None:
|
|
1285
1368
|
if self._fixed_key is not None:
|
|
@@ -1287,6 +1370,13 @@ class LoginCodes:
|
|
|
1287
1370
|
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
1288
1371
|
|
|
1289
1372
|
|
|
1373
|
+
def _session_bound(value: Any, longest: int) -> int:
|
|
1374
|
+
"""A session bound in seconds: a finite number, never longer than the contract's (DEC-0039)."""
|
|
1375
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value in (float("inf"), float("-inf")):
|
|
1376
|
+
raise ServiceError("a session bound must be a finite number of seconds.")
|
|
1377
|
+
return min(int(value), longest)
|
|
1378
|
+
|
|
1379
|
+
|
|
1290
1380
|
class MemoryCodeStore:
|
|
1291
1381
|
"""DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
|
|
1292
1382
|
|
|
@@ -1304,15 +1394,21 @@ SESSION_COOKIE = "fabric_session"
|
|
|
1304
1394
|
REMOTE_SESSION_COOKIE = "__Host-fabric_session"
|
|
1305
1395
|
|
|
1306
1396
|
|
|
1307
|
-
def session_cookie_header(value: str, max_age: int =
|
|
1397
|
+
def session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
1398
|
+
"""The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner."""
|
|
1308
1399
|
return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
|
|
1309
1400
|
|
|
1310
1401
|
|
|
1311
|
-
def remote_session_cookie_header(value: str, max_age: int =
|
|
1402
|
+
def remote_session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
1312
1403
|
"""DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
|
|
1313
1404
|
return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
|
|
1314
1405
|
|
|
1315
1406
|
|
|
1407
|
+
def expired_session_cookie_header(remote: bool = False) -> str:
|
|
1408
|
+
"""The header that clears the browser's session cookie, sent with a logout."""
|
|
1409
|
+
return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
|
|
1410
|
+
|
|
1411
|
+
|
|
1316
1412
|
def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
|
|
1317
1413
|
sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
|
|
1318
1414
|
"""DEC-0019: the request guard of an online service; returns the refusal sentence or None.
|
|
@@ -27,7 +27,9 @@ if (!origin || k.remoteOriginProblems(origin).length) { console.error(`origin: $
|
|
|
27
27
|
if (token.length < 16) { console.error('the service token is missing or shorter than 16 characters'); process.exit(2); }
|
|
28
28
|
|
|
29
29
|
const startedAt = k.nowIso();
|
|
30
|
-
|
|
30
|
+
// DEC-0039: sessions end 12 h after last use, 7 days after sign-in, on logout and when the token rotates.
|
|
31
|
+
// The memory store forgets them on a deploy; the host signs in again on the page's 401.
|
|
32
|
+
const codes = new k.LoginCodes(null, 120, { store: new k.MemoryCodeStore(), key, token });
|
|
31
33
|
const events = [k.makeEvent('1', startedAt, 'service.started', 'info', 'The example agent started.')];
|
|
32
34
|
const tlsSelf = Boolean(args['tls-cert']);
|
|
33
35
|
|
|
@@ -65,6 +67,12 @@ function handler(req, res) {
|
|
|
65
67
|
if (!session) return send(res, 403, 'This login link has expired or was used. Open the dashboard from Fabric again.', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
66
68
|
return send(res, 302, null, { Location: '/', 'Set-Cookie': k.remoteSessionCookieHeader(session) });
|
|
67
69
|
}
|
|
70
|
+
if (req.method === 'POST' && url.pathname === '/logout') {
|
|
71
|
+
// A browser write: the custom header keeps another site's form from signing the operator out.
|
|
72
|
+
if (req.headers['x-fabric-request'] !== '1') return send(res, 403, 'Missing request header.', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
73
|
+
codes.logout(k.cookieValue(req.headers.cookie, k.REMOTE_SESSION_COOKIE));
|
|
74
|
+
return send(res, 204, null, { 'Set-Cookie': k.expiredSessionCookieHeader(true) });
|
|
75
|
+
}
|
|
68
76
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
69
77
|
if (!codes.sessionValid(k.cookieValue(req.headers.cookie, k.REMOTE_SESSION_COOKIE))) return send(res, 401, 'Sign in from Fabric Dashboards.', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
70
78
|
return send(res, 200, '<!doctype html><meta charset="utf-8"><title>Example Agent</title><h1>Example Agent</h1>', { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'" });
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -84,7 +84,8 @@ class Service:
|
|
|
84
84
|
self.log = fs.JsonlEventLog(self.data / "events.jsonl")
|
|
85
85
|
self.usage = fs.JsonlUsageLedger(self.data / "usage.jsonl") # DEC-0021
|
|
86
86
|
self.usage.prune()
|
|
87
|
-
|
|
87
|
+
# DEC-0039: the token= revision ends sessions begun under a token that has since rotated.
|
|
88
|
+
self.codes = fs.LoginCodes(self.data / "auth", token=self.token)
|
|
88
89
|
self.jobs = fi.JobStore(self.data / "jobs")
|
|
89
90
|
self.mcp = fi.McpToolServer(self.id, VERSION, jobs=self.jobs, on_job_started=self.draft_started, on_input=self.draft_answered)
|
|
90
91
|
for cap in CAPABILITIES:
|
|
@@ -255,6 +256,12 @@ def make_handler(svc: Service):
|
|
|
255
256
|
if not self._token_ok():
|
|
256
257
|
return
|
|
257
258
|
return self._send(200, svc.codes.issue())
|
|
259
|
+
if url.path == "/logout":
|
|
260
|
+
# A browser write: the custom header keeps another site's form from signing the operator out.
|
|
261
|
+
if self.headers.get("X-Fabric-Request") != "1":
|
|
262
|
+
return self._send(403, {"error": "Missing request header."})
|
|
263
|
+
svc.codes.logout(fs.cookie_value(self.headers.get("Cookie")))
|
|
264
|
+
return self._send(204, None, {"Set-Cookie": fs.expired_session_cookie_header()})
|
|
258
265
|
if url.path == "/api/emit":
|
|
259
266
|
if not (self._session_ok() or fs.token_matches(self.headers.get("Authorization"), svc.token)):
|
|
260
267
|
return self._send(401, {"error": "Sign in first."})
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.11.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -119,7 +119,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
119
119
|
--schema-base <immutable-base-uri>
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
Pin exactly contract `0.1.0` at commit `
|
|
122
|
+
Pin exactly contract `0.1.0` at commit `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5` and
|
|
123
123
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
124
124
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
125
125
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|