@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.
Files changed (20) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +3 -3
  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 +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +84 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -3
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +57 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +427 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +92 -16
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +761 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +43 -10
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +130 -34
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -1
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -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
- self._save({"job": job, "private": {"capability": capability, "input": arguments,
289
- "traceparent": traceparent if parse_traceparent(traceparent) else None}})
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
- return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span)}
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) -> Dict[str, Any]:
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)
@@ -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
- and HMAC-signed session cookies revoked by rotating the key."""
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
- # A local service keeps codes and the key in files. An online one (DEC-0019) usually has no
1212
- # durable disk: pass state_dir=None with store=MemoryCodeStore() (a restart forgets every
1213
- # code, so none can be replayed) and key= bytes from a platform secret (sessions survive a deploy).
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 = time.time() - 3600
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
- code = secrets.token_urlsafe(24)
1251
- expires = time.time() + self.ttl
1252
- codes = self._load()
1253
- codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
1254
- self._save(codes)
1255
- at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
1256
- return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
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
- if not code or not _CODE.match(code):
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
- digest = hashlib.sha256(code.encode()).hexdigest()
1263
- codes = self._load()
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
- entry["used"] = True
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 self._sign(secrets.token_urlsafe(18))
1352
+ return parts[1], int(parts[2])
1272
1353
 
1273
- def _sign(self, session_id: str) -> str:
1274
- mac = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
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 session_valid(self, cookie_value: Optional[str]) -> bool:
1278
- if not cookie_value or "." not in cookie_value:
1279
- return False
1280
- session_id, mac = cookie_value.rsplit(".", 1)
1281
- expected = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
1282
- return hmac.compare_digest(mac, expected)
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 = 30 * 86400) -> str:
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 = 30 * 86400) -> str:
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
- const codes = new k.LoginCodes(null, 120, { store: new k.MemoryCodeStore(), key });
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'" });
@@ -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
- self.codes = fs.LoginCodes(self.data / "auth")
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.9.0"
8
+ version: "0.11.1"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "a83bec6b47776af688402c18efec507143061f68"
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 `a83bec6b47776af688402c18efec507143061f68` and
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