@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.
- package/CHANGELOG.md +100 -0
- package/README.md +14 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
- 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 +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -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_dashboard_layout.mjs +295 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +143 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +195 -36
- 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 +29 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
664
|
-
//
|
|
665
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
791
|
+
const now = this.clock();
|
|
792
|
+
if (entry.expires < now) return null;
|
|
716
793
|
const session = crypto.randomBytes(18).toString('base64url');
|
|
717
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
const
|
|
725
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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 =
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
|
1352
|
+
return parts[1], int(parts[2])
|
|
1209
1353
|
|
|
1210
|
-
def
|
|
1211
|
-
|
|
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
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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'" });
|