@passioncode-ai/fabric-agent-adapter 0.8.3 → 0.10.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 (26) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -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/hooks/hooks.json +17 -0
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +88 -5
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +143 -17
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +195 -36
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +29 -4
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -11,6 +11,11 @@ import path from 'node:path';
11
11
 
12
12
  export const PROTOCOL = 'fabric-service/0.1';
13
13
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
14
+ // The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
15
+ // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
16
+ export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
17
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.0.0';
18
+ export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
14
19
  export const EXIT_ALREADY_RUNNING = 75;
15
20
  // Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
16
21
  // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
@@ -339,7 +344,45 @@ export function removeDescriptor(id, instance = 'default', dir = servicesDir())
339
344
 
340
345
  // --- well-known and events ------------------------------------------------------------
341
346
 
342
- export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid }) {
347
+ const THEME_ID = /^[a-z][a-z0-9-]{0,31}$/;
348
+ const KIT_REF = /^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
349
+
350
+ // What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json).
351
+ export function dashboardExtensionProblems(value) {
352
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return ['the declaration is not an object'];
353
+ const out = [];
354
+ const extra = Object.keys(value).filter((k) => !['minWidth', 'appearance', 'themes', 'kit'].includes(k)).sort();
355
+ if (extra.length) out.push(`unknown field(s): ${extra.join(', ')}`);
356
+ const w = value.minWidth;
357
+ if (!Number.isInteger(w)) out.push('minWidth is required and must be a whole number of CSS pixels');
358
+ else if (w < 360 || w > 1280) out.push(`minWidth ${w} is outside 360…1280`);
359
+ if ('appearance' in value) {
360
+ const a = value.appearance;
361
+ if (!Array.isArray(a) || a.length > 2 || new Set(a).size !== a.length || a.some((x) => x !== 'scheme' && x !== 'theme')) out.push('appearance is a list of distinct fields from scheme, theme');
362
+ }
363
+ if ('themes' in value) {
364
+ const t = value.themes;
365
+ if (!Array.isArray(t) || t.length < 1 || t.length > 32 || new Set(t).size !== t.length || t.some((x) => typeof x !== 'string' || !THEME_ID.test(x))) out.push('themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$');
366
+ }
367
+ if ('kit' in value && !(typeof value.kit === 'string' && KIT_REF.test(value.kit))) out.push(`kit is name@semver, for example ${DASHBOARD_KIT}`);
368
+ return out;
369
+ }
370
+
371
+ // The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
372
+ // when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that.
373
+ export function dashboardExtension({ minWidth = 360, appearance = ['scheme', 'theme'], themes = REFERENCE_THEMES, kit = DASHBOARD_KIT } = {}) {
374
+ const value = { minWidth };
375
+ if (appearance?.length) value.appearance = [...appearance];
376
+ if (themes?.length) value.themes = [...themes];
377
+ if (kit) value.kit = kit;
378
+ const problems = dashboardExtensionProblems(value);
379
+ if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
380
+ return { [DASHBOARD_EXTENSION_KEY]: value };
381
+ }
382
+
383
+ // `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration in
384
+ // it must be valid (dashboardExtension builds one).
385
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions }) {
343
386
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
344
387
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
345
388
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
@@ -353,6 +396,13 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
353
396
  update: { available: updateAvailable },
354
397
  };
355
398
  if (summary?.length) doc.summary = summary.slice(0, 6);
399
+ if (extensions && Object.keys(extensions).length) {
400
+ if (DASHBOARD_EXTENSION_KEY in extensions) {
401
+ const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
402
+ if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
403
+ }
404
+ doc.extensions = { ...extensions };
405
+ }
356
406
  return doc;
357
407
  }
358
408
 
@@ -657,15 +707,41 @@ export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
657
707
 
658
708
  // --- operator login -------------------------------------------------------------------
659
709
 
710
+ /** DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session. */
711
+ export const OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60;
712
+ export const OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60;
713
+
714
+ // Compare bytes, not UTF-16 units: a non-ASCII value of the right length must be a refusal, not a RangeError.
715
+ const safeEqual = (a, b) => {
716
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
717
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
718
+ };
719
+ const sessionBound = (value, longest) => {
720
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new ServiceError('a session bound must be a finite number of seconds.');
721
+ return Math.min(Math.trunc(value), longest);
722
+ };
723
+
724
+ // Single-use login codes, and operator sessions with the DEC-0039 lifetime: a session ends 12 h after its
725
+ // last use, 7 days after the code was redeemed, on logout, and when the descriptor token rotates. The server
726
+ // enforces all four: the cookie carries a signed claim (session id, sign-in time, token revision) and the
727
+ // code store keeps a record per session (last use, logout). Rotating the key with revokeAll() ends every one.
660
728
  export class LoginCodes {
661
- // `stateDir` — a local service keeps codes and the session key in files. An online service
662
- // (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store`
663
- // like `new MemoryCodeStore()` (a restart forgets every code, so none can be replayed) and
664
- // `key` a Buffer from a platform secret, so sessions survive a deploy.
665
- constructor(stateDir, ttlSeconds = 120, { store = null, key = null } = {}) {
729
+ // `stateDir` — a local service keeps codes, sessions and the session key in files. An online service
730
+ // (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store` like
731
+ // `new MemoryCodeStore()` and `key` a Buffer from a platform secret. A memory store forgets codes AND
732
+ // sessions on a restart, so a deploy signs the operator out (the host signs in again on the page's 401);
733
+ // pass a durable store to keep sessions. `token` is the descriptor token, or a function returning the
734
+ // current one: a session begun under one token is refused once it rotates. A service MAY shorten
735
+ // `idleSeconds` or `maxSeconds`, never lengthen them.
736
+ constructor(stateDir, ttlSeconds = 120, { store = null, key = null, token = null,
737
+ idleSeconds = OPERATOR_SESSION_IDLE_SECONDS, maxSeconds = OPERATOR_SESSION_MAX_SECONDS, clock = () => Date.now() / 1000 } = {}) {
666
738
  this.ttl = Math.min(ttlSeconds, 120);
739
+ this.idleSeconds = sessionBound(idleSeconds, OPERATOR_SESSION_IDLE_SECONDS);
740
+ this.maxSeconds = sessionBound(maxSeconds, OPERATOR_SESSION_MAX_SECONDS);
667
741
  this.store = store;
668
742
  this.fixedKey = key;
743
+ this.token = token;
744
+ this.clock = clock;
669
745
  if (stateDir) {
670
746
  this.dir = ensurePrivateDir(stateDir);
671
747
  this.keyPath = path.join(this.dir, 'session.key');
@@ -688,7 +764,7 @@ export class LoginCodes {
688
764
  }
689
765
 
690
766
  save(codes) {
691
- const horizon = Date.now() / 1000 - 3600;
767
+ const horizon = this.clock() - 3600;
692
768
  const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
693
769
  if (this.store) { this.store.save(kept); return; }
694
770
  atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
@@ -696,7 +772,7 @@ export class LoginCodes {
696
772
 
697
773
  issue() {
698
774
  const code = crypto.randomBytes(24).toString('base64url');
699
- const expires = Date.now() / 1000 + this.ttl;
775
+ const expires = this.clock() + this.ttl;
700
776
  const codes = this.load();
701
777
  codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
702
778
  this.save(codes);
@@ -712,19 +788,66 @@ export class LoginCodes {
712
788
  if (!entry || entry.used) return null;
713
789
  entry.used = true;
714
790
  this.save(codes);
715
- if (entry.expires < Date.now() / 1000) return null;
791
+ const now = this.clock();
792
+ if (entry.expires < now) return null;
716
793
  const session = crypto.randomBytes(18).toString('base64url');
717
- return `${session}.${crypto.createHmac('sha256', this.key()).update(session).digest('hex')}`;
794
+ const signedInAt = Math.trunc(now);
795
+ codes[LoginCodes.recordKey(session)] = { expires: signedInAt + this.maxSeconds, signedInAt, lastUsedAt: now };
796
+ this.save(codes);
797
+ const claim = `v2.${session}.${signedInAt}.${this.tokenRevision()}`;
798
+ return `${claim}.${this.mac(claim)}`;
718
799
  }
719
800
 
801
+ /** True while the session is live; a use within the bounds refreshes the idle bound. */
720
802
  sessionValid(value) {
721
- if (!value || !value.includes('.')) return false;
722
- const i = value.lastIndexOf('.');
723
- const expected = crypto.createHmac('sha256', this.key()).update(value.slice(0, i)).digest('hex');
724
- const mac = value.slice(i + 1);
725
- return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
803
+ const parsed = this.parse(value);
804
+ if (!parsed) return false;
805
+ const [session, signedInAt] = parsed;
806
+ const now = this.clock();
807
+ if (now >= signedInAt + this.maxSeconds) return false;
808
+ const codes = this.load();
809
+ const record = codes[LoginCodes.recordKey(session)];
810
+ if (!record || record.signedInAt !== signedInAt || record.loggedOutAt != null) return false;
811
+ const last = Number(record.lastUsedAt ?? 0);
812
+ if (now >= last + this.idleSeconds) return false;
813
+ if (now - last >= Math.min(60, Math.max(1, Math.trunc(this.idleSeconds / 10)))) {
814
+ record.lastUsedAt = now;
815
+ this.save(codes);
816
+ }
817
+ return true;
726
818
  }
727
819
 
820
+ /** End this session on the server; clear the browser's copy with expiredSessionCookieHeader(). */
821
+ logout(value) {
822
+ const parsed = this.parse(value);
823
+ if (!parsed) return false;
824
+ const codes = this.load();
825
+ const record = codes[LoginCodes.recordKey(parsed[0])];
826
+ if (!record || record.signedInAt !== parsed[1] || record.loggedOutAt != null) return false;
827
+ record.loggedOutAt = this.clock();
828
+ this.save(codes);
829
+ return true;
830
+ }
831
+
832
+ parse(value) {
833
+ if (typeof value !== 'string' || !/^[\x21-\x7e]*$/.test(value)) return null;
834
+ const parts = value.split('.');
835
+ if (parts.length !== 5 || parts[0] !== 'v2' || !parts[1] || !/^\d+$/.test(parts[2])) return null;
836
+ if (!safeEqual(parts[4], this.mac(parts.slice(0, 4).join('.')))) return null;
837
+ if (!safeEqual(parts[3], this.tokenRevision())) return null;
838
+ return [parts[1], Number(parts[2])];
839
+ }
840
+
841
+ mac(claim) { return crypto.createHmac('sha256', this.key()).update(claim).digest('hex'); }
842
+
843
+ tokenRevision() {
844
+ const token = typeof this.token === 'function' ? this.token() : this.token;
845
+ if (!token) return '-';
846
+ return crypto.createHmac('sha256', this.key()).update(`token-revision\0${token}`).digest('hex').slice(0, 16);
847
+ }
848
+
849
+ static recordKey(session) { return `s:${crypto.createHash('sha256').update(session).digest('hex')}`; }
850
+
728
851
  revokeAll() {
729
852
  if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
730
853
  atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
@@ -738,9 +861,12 @@ export class MemoryCodeStore {
738
861
  save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
739
862
  }
740
863
 
741
- export const sessionCookieHeader = (value, maxAge = 30 * 86400) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
864
+ /** The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner. */
865
+ export const sessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
742
866
  /** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
743
- export const remoteSessionCookieHeader = (value, maxAge = 30 * 86400) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
867
+ export const remoteSessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
868
+ /** The header that clears the browser's session cookie, sent with a logout. */
869
+ export const expiredSessionCookieHeader = (remote = false) => (remote ? remoteSessionCookieHeader : sessionCookieHeader)('', 0);
744
870
 
745
871
  /**
746
872
  * DEC-0019: the request guard of an online service. `origin` is the service's own origin
@@ -40,6 +40,11 @@ import urllib.request
40
40
 
41
41
  PROTOCOL = "fabric-service/0.1"
42
42
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
43
+ # The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
44
+ # document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
45
+ DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
46
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.0.0"
47
+ REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
43
48
  EXIT_ALREADY_RUNNING = 75
44
49
  # Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
45
50
  # the lock held backs off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive
@@ -58,6 +63,9 @@ DEFAULT_EXIT_TIMEOUT = 15
58
63
  LOG_MAX_BYTES = 5 * 1024 * 1024
59
64
  LOG_BACKUPS = 5
60
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
61
69
  EVENTS_DEFAULT_LIMIT = 50
62
70
  EVENTS_MAX_LIMIT = 200
63
71
  LEVELS = ("info", "notice", "warning", "error")
@@ -701,11 +709,63 @@ def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] =
701
709
  return info
702
710
 
703
711
 
712
+ _THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
713
+ _KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
714
+
715
+
716
+ def dashboard_extension_problems(value: Any) -> List[str]:
717
+ """What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
718
+ if not isinstance(value, dict):
719
+ return ["the declaration is not an object"]
720
+ out: List[str] = []
721
+ extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
722
+ if extra:
723
+ out.append("unknown field(s): %s" % ", ".join(extra))
724
+ width = value.get("minWidth")
725
+ if isinstance(width, bool) or not isinstance(width, int):
726
+ out.append("minWidth is required and must be a whole number of CSS pixels")
727
+ elif not 360 <= width <= 1280:
728
+ out.append("minWidth %d is outside 360…1280" % width)
729
+ if "appearance" in value:
730
+ a = value["appearance"]
731
+ if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
732
+ out.append("appearance is a list of distinct fields from scheme, theme")
733
+ if "themes" in value:
734
+ t = value["themes"]
735
+ if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
736
+ or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
737
+ out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
738
+ if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
739
+ out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
740
+ return out
741
+
742
+
743
+ def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
744
+ themes: Optional[Sequence[str]] = REFERENCE_THEMES,
745
+ kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
746
+ """The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
747
+ when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
748
+ value: Dict[str, Any] = {"minWidth": min_width}
749
+ if appearance:
750
+ value["appearance"] = list(appearance)
751
+ if themes:
752
+ value["themes"] = list(themes)
753
+ if kit:
754
+ value["kit"] = kit
755
+ problems = dashboard_extension_problems(value)
756
+ if problems:
757
+ raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
758
+ return {DASHBOARD_EXTENSION_KEY: value}
759
+
760
+
704
761
  def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
705
762
  started_at: str, status: str, degraded: Sequence[Dict[str, str]],
706
763
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
707
- update_available: Optional[str] = None, pid: Optional[int] = None) -> Dict[str, Any]:
708
- """The unauthenticated identity answer. `degraded` is always present; ready means empty."""
764
+ update_available: Optional[str] = None, pid: Optional[int] = None,
765
+ extensions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
766
+ """The unauthenticated identity answer. `degraded` is always present; ready means empty.
767
+ `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
768
+ in it must be valid (dashboard_extension builds one)."""
709
769
  if status not in STATUSES:
710
770
  raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
711
771
  degraded = list(degraded)
@@ -724,6 +784,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
724
784
  }
725
785
  if summary:
726
786
  doc["summary"] = list(summary)[:6]
787
+ if extensions:
788
+ if DASHBOARD_EXTENSION_KEY in extensions:
789
+ problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
790
+ if problems:
791
+ raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
792
+ doc["extensions"] = dict(extensions)
727
793
  return doc
728
794
 
729
795
 
@@ -1140,17 +1206,35 @@ class Drain:
1140
1206
  # --- operator login ----------------------------------------------------------
1141
1207
 
1142
1208
  class LoginCodes:
1143
- """Single-use login codes (<=120 s), recorded as used BEFORE they are honoured,
1144
- 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."""
1145
1214
 
1146
1215
  def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
1147
- store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None):
1148
- # A local service keeps codes and the key in files. An online one (DEC-0019) usually has no
1149
- # durable disk: pass state_dir=None with store=MemoryCodeStore() (a restart forgets every
1150
- # 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.
1151
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()
1152
1234
  self._store = store
1153
1235
  self._fixed_key = key
1236
+ self._token = token
1237
+ self._clock = clock
1154
1238
  if state_dir is not None:
1155
1239
  self.dir = ensure_private_dir(Path(state_dir))
1156
1240
  self._key_path = self.dir / "session.key"
@@ -1176,7 +1260,7 @@ class LoginCodes:
1176
1260
  return {}
1177
1261
 
1178
1262
  def _save(self, codes: Dict[str, Any]) -> None:
1179
- horizon = time.time() - 3600
1263
+ horizon = self._clock() - 3600
1180
1264
  codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
1181
1265
  if self._store is not None:
1182
1266
  self._store.save(codes)
@@ -1184,39 +1268,101 @@ class LoginCodes:
1184
1268
  atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
1185
1269
 
1186
1270
  def issue(self) -> Dict[str, str]:
1187
- code = secrets.token_urlsafe(24)
1188
- expires = time.time() + self.ttl
1189
- codes = self._load()
1190
- codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
1191
- self._save(codes)
1192
- at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
1193
- 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")}
1194
1279
 
1195
1280
  def redeem(self, code: Optional[str]) -> Optional[str]:
1196
1281
  """Return a session cookie value, or None. The code is burned even when it has expired."""
1197
- 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():
1198
1346
  return None
1199
- digest = hashlib.sha256(code.encode()).hexdigest()
1200
- codes = self._load()
1201
- entry = codes.get(digest)
1202
- 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)):
1203
1349
  return None
1204
- entry["used"] = True
1205
- self._save(codes)
1206
- if entry.get("expires", 0) < time.time():
1350
+ if not hmac.compare_digest(parts[3], self._token_revision()):
1207
1351
  return None
1208
- return self._sign(secrets.token_urlsafe(18))
1352
+ return parts[1], int(parts[2])
1209
1353
 
1210
- def _sign(self, session_id: str) -> str:
1211
- mac = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
1212
- return session_id + "." + mac
1354
+ def _mac(self, claim: str) -> str:
1355
+ return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
1213
1356
 
1214
- def session_valid(self, cookie_value: Optional[str]) -> bool:
1215
- if not cookie_value or "." not in cookie_value:
1216
- return False
1217
- session_id, mac = cookie_value.rsplit(".", 1)
1218
- expected = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
1219
- 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()
1220
1366
 
1221
1367
  def revoke_all(self) -> None:
1222
1368
  if self._fixed_key is not None:
@@ -1224,6 +1370,13 @@ class LoginCodes:
1224
1370
  atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
1225
1371
 
1226
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
+
1227
1380
  class MemoryCodeStore:
1228
1381
  """DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
1229
1382
 
@@ -1241,15 +1394,21 @@ SESSION_COOKIE = "fabric_session"
1241
1394
  REMOTE_SESSION_COOKIE = "__Host-fabric_session"
1242
1395
 
1243
1396
 
1244
- 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."""
1245
1399
  return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
1246
1400
 
1247
1401
 
1248
- 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:
1249
1403
  """DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
1250
1404
  return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
1251
1405
 
1252
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
+
1253
1412
  def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
1254
1413
  sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
1255
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'" });