@passioncode-ai/fabric-agent-adapter 0.9.0 → 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 CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-10-11
4
+
5
+ Contract DEC-0039, first part (FAA-20, issue #52): the operator's dashboard session now lives no longer than
6
+ the contract allows, and the server enforces it; the probe treats `surfaces.mcp.auth: "delegation"` like `own`.
7
+ The contract pin moves to Fabric Agent Contract `main` `8e49fbd` (the DEC-0039 merge, contract PR #30). Bundles
8
+ issued under the DEC-0036, DEC-0035, DEC-0033, DEC-0031, DEC-0024, DEC-0021, DEC-0020 and DEC-0019 revisions
9
+ stay valid.
10
+
11
+ ### Security
12
+
13
+ - **Operator sessions end on the server (DEC-0039).** Until now `LoginCodes.session_valid` checked only an HMAC
14
+ and the cookie lived 30 days, so a copied cookie stayed valid until the session key was rotated. Now a
15
+ session ends 12 h after its last use, 7 days after the login code was redeemed however often it is used, on
16
+ `logout`, and when the descriptor token rotates. The cookie carries a signed claim (session id, sign-in time,
17
+ token revision); the code store keeps a record per session (last use, logout), so a cookie with no record is
18
+ refused. Both kits, the same behaviour.
19
+
20
+ ### Changed
21
+
22
+ - **Every operator signs in once more after the upgrade**: a cookie of the old format is refused. The host does
23
+ it on the page's `401`.
24
+ - **A remote service with `MemoryCodeStore` loses its sessions on a deploy** (it already lost its login codes):
25
+ the store now holds the session records. Pass a durable store to keep them.
26
+ - **`session_cookie_header` / `remote_session_cookie_header`** (and the Node `sessionCookieHeader` /
27
+ `remoteSessionCookieHeader`) default to `Max-Age` 604800 (7 days), not 30 days.
28
+ - **Default contract pin `a83bec6` → `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5`.** `a83bec6` (DEC-0036) joins
29
+ `SUPPORTED_CONTRACT_COMMITS`. The revisions between (DEC-0037 spend policy, DEC-0038 grant windows,
30
+ DEC-0039 delegation, DEC-0040 Fabric's button) add schemas and optional properties only: the provider
31
+ manifest gains optional cost presets, the governance grant's `effects` may be omitted by a delegation grant,
32
+ and the well-known document gains `auth.delegation` and the `delegation` value of `surfaces.mcp.auth`.
33
+ Every descriptor, well-known document and bundle the kits wrote before stays valid.
34
+
35
+ ### Added
36
+
37
+ - **`LoginCodes(..., token=, idle_seconds=, max_seconds=, clock=)`** (`{ token, idleSeconds, maxSeconds, clock }`
38
+ in Node): `token` is the descriptor token or a callable returning the current one; the bounds may be
39
+ shortened, and longer values are clamped. **`logout(cookie)`** and **`expired_session_cookie_header(remote=)`**
40
+ (`logout`, `expiredSessionCookieHeader`); `OPERATOR_SESSION_IDLE_SECONDS`, `OPERATOR_SESSION_MAX_SECONDS`.
41
+ - **`check_service.py`**: `surfaces.mcp.auth: "delegation"` is checked like `own` — the MCP surface must refuse
42
+ the descriptor token, and the caller rules are `NOT_RUN` (DL-16); rule **`auth.delegation-declared`** (with
43
+ `delegation`, the well-known document names `auth.delegation {issuer, jwks}`); rule
44
+ **`login.session-lifetime`** (the cookie set at sign-in does not outlive 7 days).
45
+ - **The sample services** pass `token=` and serve `POST /logout` (a browser write: it needs `X-Fabric-Request`).
46
+ - `references/surfaces-and-auth.md`, `remote-placement.md` and `protocol.md` describe the lifetime, logout and
47
+ DL-16. Scenario evals `operator-session-lifetime` and `mcp-auth-delegation` came first.
48
+
49
+ ### Not yet
50
+
51
+ - Verifying delegation tokens and approvals in the kits (`verifyDelegation` / `verifyApproval`) and Fabric's
52
+ button in the dashboard kit (FB-7) follow in a later release (FAA-20 stays open).
53
+
3
54
  ## 0.9.0 - 2026-10-11
4
55
 
5
56
  The dashboard kit (`fabric-dashboard/0.1`, contract DEC-0036): a Fabric service's dashboard holds inside a
package/README.md CHANGED
@@ -202,7 +202,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
202
202
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
203
203
 
204
204
  - version: `0.1.0`
205
- - commit: `a83bec6b47776af688402c18efec507143061f68`
205
+ - commit: `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5`
206
206
 
207
207
  Every other mention of the default contract revision — this section, the skills' metadata, the
208
208
  skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
@@ -229,8 +229,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
229
229
  ```
230
230
 
231
231
  Without both environment variables, those tests explicitly skip as `NOT_RUN`.
232
- This source checkout is 0.9.0. 0.8.3 is the latest published GitHub/npm release (2026-10-10; 0.7.0 was never
233
- released); 0.9.0 is published by its `v0.9.0` tag. Source delivery does not update installed skills or live sessions.
232
+ This source checkout is 0.10.0. 0.9.0 is the latest published GitHub/npm release (2026-10-11; 0.7.0 was never
233
+ released); 0.10.0 is published by its `v0.10.0` tag. Source delivery does not update installed skills or live sessions.
234
234
 
235
235
  ## License
236
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.9.0",
6
+ "version": "0.10.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -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.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.9.0"
8
+ version: "0.10.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "a83bec6b47776af688402c18efec507143061f68"
10
+ contract-commit: "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,7 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `a83bec6b47776af688402c18efec507143061f68`.
73
+ - commit `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5`.
74
74
 
75
75
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
76
76
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -16,10 +16,10 @@ from urllib.parse import urlparse
16
16
 
17
17
  CONTRACT_VERSION = "0.1.0"
18
18
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
19
- CONTRACT_COMMIT = "a83bec6b47776af688402c18efec507143061f68"
19
+ CONTRACT_COMMIT = "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
20
20
  # #region supported-contract-revisions — docs: README.md#contract-pin
21
21
  # Only this declaration may name a legacy revision in live files.
22
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
22
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
23
23
  # #endregion supported-contract-revisions
24
24
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
25
25
  MCP_REVISION = "2026-07-28"
@@ -16,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.9.0"
19
+ version: "0.10.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
- extension-commit: "a83bec6b47776af688402c18efec507143061f68"
22
+ extension-commit: "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
23
23
  ---
24
24
 
25
25
  # Building Fabric services
@@ -1,11 +1,20 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `a83bec6b47776af688402c18efec507143061f68`
3
+ Pinned to `fabric-agent-contract` commit `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
- DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036). The contract's schemas are normative;
5
+ DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
+ delegation and the operator session's lifetime DEC-0039). The contract's schemas are normative;
6
7
  this page is the working summary. Extension key:
7
8
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
8
9
 
10
+ ## Contents
11
+
12
+ - Descriptor — written by the installer
13
+ - Well-known document
14
+ - Events page
15
+ - Login code and the operator session
16
+ - Semantic rules a host applies
17
+
9
18
  ## Descriptor — written by the installer
10
19
 
11
20
  Directory: macOS `~/Library/Application Support/ai.passioncode.fabric/services/`,
@@ -59,6 +68,11 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
59
68
  ED-9): `minWidth` 360…1280 (required), `appearance` ⊆ `scheme`, `theme`, `themes`, `kit`
60
69
  (`name@semver`), nothing else. The kits build it with `dashboard_extension()` /
61
70
  `dashboardExtension()`; see [the dashboard kit reference](dashboard-kit.md).
71
+ - `surfaces.mcp.auth`: `descriptor` (default), `own` (DEC-0024) or `delegation` (DEC-0039, DL-16):
72
+ the MCP surface takes only the estate's delegation tokens, and the top-level
73
+ `auth.delegation {issuer, jwks}` is then required. With `own` or `delegation` the descriptor's
74
+ token is not an MCP credential: a host and the probe never call `/mcp` with it as a caller.
75
+ The kits do not verify delegation tokens yet (a later FAA-20 release).
62
76
 
63
77
  ## Events page — `GET /fabric/v1/events?after=<cursor>&limit=<n>`
64
78
 
@@ -78,11 +92,14 @@ empty, or `null` for an empty log. Retain at least seven days or 1000 events.
78
92
  path on the service origin, never an absolute URL. Optional SSE stream at
79
93
  `surfaces.events.stream` carrying the same objects.
80
94
 
81
- ## Login code — `POST /fabric/v1/login-code`
95
+ ## Login code and the operator session — `POST /fabric/v1/login-code`
82
96
 
83
97
  Token required → `{ "url": "/fabric/v1/login?code=<16–256 url-safe chars>", "expiresAt": "…" }`,
84
98
  single use, at most 120 s, recorded as used before it is honoured. `GET` that URL →
85
99
  `Set-Cookie` `HttpOnly; SameSite=Strict`, `302` to `surfaces.dashboard.path`.
100
+ The session it starts ends 12 h after its last use, 7 days after sign-in, on logout and when the
101
+ descriptor token rotates (DEC-0039); a service may shorten either bound, never lengthen one. An
102
+ ended session answers `401` on the dashboard page.
86
103
 
87
104
  ## Semantic rules a host applies
88
105
 
@@ -36,9 +36,11 @@ same objects — well-known document, events feed, login code — at the same pa
36
36
  - **Token**: a random value of at least 16 characters (32 bytes recommended), set as a secret;
37
37
  the same value is written on the operator's computer by `registerRemote`. Compare in constant
38
38
  time (`tokenMatches`).
39
- - **Session key**: 32 random bytes, a separate secret. With it, sessions survive a deploy; without
40
- it, every deploy signs the operator out.
41
- - **Codes**: `MemoryCodeStore` — a restart forgets every outstanding code, so none can be replayed.
39
+ - **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
40
+ - **Codes and sessions**: `MemoryCodeStore` — a restart forgets every outstanding code, so none
41
+ can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
42
+ server keeps each session's last use and logout). The host signs in again on the page's `401`.
43
+ To keep sessions across a deploy, pass a durable store (any object with `load()` and `save()`).
42
44
  Several processes behind one origin need a shared store (a database row with the same
43
45
  "used before honoured" write); memory works for one process.
44
46
  - **Behind a router**: check the platform-set forwarded scheme; `Host` is preserved by common
@@ -68,4 +70,5 @@ reach the service, and still need its own `403`.
68
70
 
69
71
  Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
70
72
  operator's computer with it; the old token stops working the moment the platform restarts with the
71
- new value. Rotate the session key on the platform to sign every operator out.
73
+ new value, and so does every session begun under it when `LoginCodes` has `token=`. Rotate the
74
+ session key on the platform to sign every operator out.
@@ -12,6 +12,11 @@
12
12
  for agents, `host.token` named by the descriptor), declare `surfaces.mcp.auth: "own"`, and
13
13
  make `/mcp` refuse the host's token. `check_service.py` then checks only that refusal
14
14
  (`interop.mcp-own-auth`) and leaves the caller rules NOT_RUN.
15
+ - **Delegation (DEC-0039, DL-16).** `surfaces.mcp.auth: "delegation"` means `/mcp` takes only
16
+ the estate's delegation tokens; declare `auth.delegation {issuer, jwks}` in the well-known
17
+ document. The probe treats it like `own` and checks the declaration
18
+ (`auth.delegation-declared`). The kits do not verify the tokens yet; until they do, name what
19
+ checks them.
15
20
  - Register it into a client config with the service's own command
16
21
  (`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
17
22
  atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
@@ -50,8 +55,20 @@ else will notice it expire.
50
55
  - `POST /fabric/v1/login-code` (token) → single-use code, at most 120 s, persisted as
51
56
  used before it is honoured, burned even when expired.
52
57
  - `GET /fabric/v1/login?code=` → `HttpOnly; SameSite=Strict` cookie, 302 to the
53
- dashboard. Sessions are HMAC-signed; `revoke_all` rotates the key and ends every
54
- session at once.
58
+ dashboard. `revoke_all` rotates the session key and ends every session at once.
59
+ - **The session's lifetime (DEC-0039).** A session ends 12 h after its last use, 7 days after
60
+ the code was redeemed however often it is used, on logout, and when the descriptor token
61
+ rotates. The server enforces all four, not the cookie: `LoginCodes` signs a claim (session
62
+ id, sign-in time, token revision) into the cookie and keeps a record per session (last use,
63
+ logout) in its code store; `session_valid` refuses a session past either bound and refreshes
64
+ the 12 hours on use (at most one write a minute). Pass `token=` (the descriptor token, or a
65
+ callable returning the current one), or a rotation goes unseen. `idle_seconds` and
66
+ `max_seconds` may shorten the bounds; longer values are clamped. The cookie headers default to
67
+ `Max-Age` 7 days, and the probe's `login.session-lifetime` fails a longer one.
68
+ - **Logout** is the service's own route and a browser write (the custom header below):
69
+ `codes.logout(cookie)` ends the session for every copy of the cookie, and
70
+ `expired_session_cookie_header()` (`expiredSessionCookieHeader()`) clears the browser's copy.
71
+ - An ended session answers `401` on the dashboard page itself, so the host can sign in again.
55
72
  - Browser writes need the cookie **and** a custom header such as `X-<Tool>-Request: 1`,
56
73
  which forces a preflight the service never answers.
57
74
  - A dashboard that is deliberately read-open to local pages declares
@@ -24,6 +24,7 @@ exist. They never call a capability.
24
24
  from __future__ import annotations
25
25
 
26
26
  import argparse
27
+ import email.utils
27
28
  import errno
28
29
  import http.client
29
30
  import json
@@ -416,6 +417,12 @@ class Probe:
416
417
  "the session cookie is __Host-, Secure, Path=/, no Domain" if host_bound else "cookie: %s" % cookie.split(";", 1)[0].split("=")[0])
417
418
  self.add("login.single-use", "PASS" if ok else "FAIL",
418
419
  "first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
420
+ if cookie:
421
+ problem = session_lifetime_problem(cookie)
422
+ self.add("login.session-lifetime", "FAIL" if problem else "PASS",
423
+ problem or "the session cookie lives at most 7 days (DEC-0039); the server's own bounds are not observable from here")
424
+ else:
425
+ self.add("login.session-lifetime", "NOT_RUN", "no session cookie was set")
419
426
 
420
427
  # interop (fabric-interop/0.1) ----------------------------------------------------
421
428
  # #region probe-interop — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#what-the-probe-checks
@@ -479,21 +486,25 @@ class Probe:
479
486
  extra = sorted(set(listed) - names)
480
487
  self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
481
488
  ("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
482
- own = bool(mcp_surface) and mcp_surface.get("auth") == "own"
489
+ auth = (mcp_surface or {}).get("auth")
490
+ # DEC-0024 own, and DEC-0039 DL-16 delegation: the descriptor's token is not an MCP credential.
491
+ own = bool(mcp_surface) and auth in ("own", "delegation")
492
+ why = "DL-16, DEC-0039" if auth == "delegation" else "DEC-0024"
493
+ self.delegation_declared_rule(mcp_surface)
483
494
  if own and self.token:
484
- # DEC-0024: the MCP surface has credentials of its own and must refuse the host's token.
495
+ # The MCP surface has credentials of its own and must refuse the host's token.
485
496
  try:
486
497
  self.mcp_call("tools/list", {})
487
- self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is own, yet the MCP surface accepted the descriptor's token")
498
+ self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is %s, yet the MCP surface accepted the descriptor's token (%s)" % (auth, why))
488
499
  except OSError as exc:
489
500
  refused = any(code in str(exc) for code in ("HTTP 401", "HTTP 403"))
490
501
  self.add("interop.mcp-own-auth", "PASS" if refused else "NOT_RUN",
491
- ("the MCP surface refuses the host's token (%s), as auth: own says" % exc) if refused else str(exc))
502
+ ("the MCP surface refuses the host's token (%s), as auth: %s says (%s)" % (exc, auth, why)) if refused else str(exc))
492
503
  except ValueError as exc:
493
504
  self.add("interop.mcp-own-auth", "NOT_RUN", str(exc))
494
505
  if not mcp_surface or not self.token or own:
495
506
  reason = ("no MCP surface" if not mcp_surface
496
- else "surfaces.mcp.auth is own: the MCP surface takes its callers' credentials, not the host's token (DEC-0024)" if own
507
+ else "surfaces.mcp.auth is %s: the MCP surface takes its callers' credentials, not the host's token (%s)" % (auth, why) if own
497
508
  else "no readable token")
498
509
  for rule in ("interop.output-schema-object", "interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
499
510
  self.add(rule, "NOT_RUN", reason)
@@ -513,6 +524,23 @@ class Probe:
513
524
  self.trace_rule(listing, sent)
514
525
  self.events_trace_rule(self.events)
515
526
 
527
+ def delegation_declared_rule(self, mcp_surface: Optional[Dict[str, Any]]) -> None:
528
+ """DL-16 (DEC-0039): a service that accepts delegation tokens names the issuer it trusts and its JWKS
529
+ in auth.delegation; with surfaces.mcp.auth delegation that declaration is required."""
530
+ auth = (self.wk or {}).get("auth")
531
+ declared = auth.get("delegation") if isinstance(auth, dict) else None
532
+ required = bool(mcp_surface) and mcp_surface.get("auth") == "delegation"
533
+ if declared is None and not required:
534
+ self.add("auth.delegation-declared", "NOT_RUN", "the service declares no delegation")
535
+ return
536
+ if not isinstance(declared, dict):
537
+ self.add("auth.delegation-declared", "FAIL", "surfaces.mcp.auth is delegation, yet auth.delegation is missing (DL-16)")
538
+ return
539
+ missing = [k for k in ("issuer", "jwks") if not isinstance(declared.get(k), str) or not declared.get(k)]
540
+ self.add("auth.delegation-declared", "FAIL" if missing else "PASS",
541
+ ("auth.delegation lacks %s (DL-16)" % ", ".join(missing)) if missing
542
+ else "auth.delegation names its issuer and JWKS (DL-16)")
543
+
516
544
  def object_root_rule(self, tools: Dict[str, Any]) -> None:
517
545
  """FAC-SEM-023 (DEC-0018): every listed tool's outputSchema, when present, has root type object —
518
546
  a client may refuse the whole tools/list otherwise, whatever the SDK in the tests accepted."""
@@ -728,6 +756,30 @@ class Probe:
728
756
  return self.results
729
757
 
730
758
 
759
+ def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
760
+ """DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
761
+ §5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
762
+ attrs = {}
763
+ for part in set_cookie.split(";")[1:]:
764
+ name, _, value = part.strip().partition("=")
765
+ attrs[name.strip().lower()] = value.strip()
766
+ bound = fs.OPERATOR_SESSION_MAX_SECONDS
767
+ if "max-age" in attrs:
768
+ if not re.fullmatch(r"-?\d+", attrs["max-age"]):
769
+ return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
770
+ if int(attrs["max-age"]) > bound:
771
+ return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
772
+ return None
773
+ if "expires" in attrs:
774
+ try:
775
+ at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
776
+ except (TypeError, ValueError, IndexError, OverflowError):
777
+ return "the session cookie's Expires %r is not a date" % attrs["expires"]
778
+ if at - (time.time() if now is None else now) > bound:
779
+ return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
780
+ return None
781
+
782
+
731
783
  def _semver(text: str) -> Tuple[int, int, int]:
732
784
  m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
733
785
  return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
@@ -707,15 +707,41 @@ export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
707
707
 
708
708
  // --- operator login -------------------------------------------------------------------
709
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.
710
728
  export class LoginCodes {
711
- // `stateDir` — a local service keeps codes and the session key in files. An online service
712
- // (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store`
713
- // like `new MemoryCodeStore()` (a restart forgets every code, so none can be replayed) and
714
- // `key` a Buffer from a platform secret, so sessions survive a deploy.
715
- 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 } = {}) {
716
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);
717
741
  this.store = store;
718
742
  this.fixedKey = key;
743
+ this.token = token;
744
+ this.clock = clock;
719
745
  if (stateDir) {
720
746
  this.dir = ensurePrivateDir(stateDir);
721
747
  this.keyPath = path.join(this.dir, 'session.key');
@@ -738,7 +764,7 @@ export class LoginCodes {
738
764
  }
739
765
 
740
766
  save(codes) {
741
- const horizon = Date.now() / 1000 - 3600;
767
+ const horizon = this.clock() - 3600;
742
768
  const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
743
769
  if (this.store) { this.store.save(kept); return; }
744
770
  atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
@@ -746,7 +772,7 @@ export class LoginCodes {
746
772
 
747
773
  issue() {
748
774
  const code = crypto.randomBytes(24).toString('base64url');
749
- const expires = Date.now() / 1000 + this.ttl;
775
+ const expires = this.clock() + this.ttl;
750
776
  const codes = this.load();
751
777
  codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
752
778
  this.save(codes);
@@ -762,19 +788,66 @@ export class LoginCodes {
762
788
  if (!entry || entry.used) return null;
763
789
  entry.used = true;
764
790
  this.save(codes);
765
- if (entry.expires < Date.now() / 1000) return null;
791
+ const now = this.clock();
792
+ if (entry.expires < now) return null;
766
793
  const session = crypto.randomBytes(18).toString('base64url');
767
- 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)}`;
768
799
  }
769
800
 
801
+ /** True while the session is live; a use within the bounds refreshes the idle bound. */
770
802
  sessionValid(value) {
771
- if (!value || !value.includes('.')) return false;
772
- const i = value.lastIndexOf('.');
773
- const expected = crypto.createHmac('sha256', this.key()).update(value.slice(0, i)).digest('hex');
774
- const mac = value.slice(i + 1);
775
- 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;
776
818
  }
777
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
+
778
851
  revokeAll() {
779
852
  if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
780
853
  atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
@@ -788,9 +861,12 @@ export class MemoryCodeStore {
788
861
  save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
789
862
  }
790
863
 
791
- 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}`;
792
866
  /** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
793
- 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);
794
870
 
795
871
  /**
796
872
  * DEC-0019: the request guard of an online service. `origin` is the service's own origin
@@ -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.10.0"
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