@passioncode-ai/passioncode 0.1.33 → 0.1.35

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 (23) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +84 -0
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -3
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +57 -5
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +427 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +92 -16
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +761 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +43 -10
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +130 -34
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -1
  22. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  23. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.35 - 2026-10-12
4
+
5
+ ### Changed
6
+
7
+ - **Fabric Agent Adapter is pinned at `v0.11.1`** (it was `v0.10.0`). A service built with the kit can now check a
8
+ delegation token and an operator's approval offline, in Python and Node, with the same answers as the Fabric Agent
9
+ Contract's own verifier (DEC-0039). It can put a gate in front of its MCP tools, so each call names the person it
10
+ is for, the calling agent and the chain, and an approval is used once. The Node kit refuses small-order Ed25519
11
+ keys itself (Node 20 and 22 accepted a forged signature under one). The contract pin is unchanged at `8e49fbd`.
12
+
13
+ ## 0.1.34 - 2026-10-11
14
+
15
+ ### Changed
16
+
17
+ - **Fabric Agent Adapter is pinned at `v0.10.0`** (it was `v0.9.0`). A security fix from Fabric Agent Contract
18
+ DEC-0039: a service built with the kit now ends an operator's dashboard session on the server. A session
19
+ ends 12 h after it was last used, 7 days after sign-in, on logout, or when the service token rotates.
20
+ Before, it lasted as long as a 30-day cookie, and the server checked only its signature. Operators sign in once more after a
21
+ service upgrades; the host does it on the page's 401. The conformance probe treats `surfaces.mcp.auth:
22
+ "delegation"` like `own` (DL-16) and checks the session cookie's lifetime. The default contract pin moves to
23
+ `8e49fbd`; bundles issued under the earlier revisions stay valid.
24
+
3
25
  ## 0.1.33 - 2026-10-11
4
26
 
5
27
  ### Changed
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.9.0",
11
+ "ref": "v0.11.1",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.9.0",
14
+ "version": "0.11.1",
15
15
  "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.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.33",
38
+ "version": "0.1.35",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.9.0",
11
- "commit": "7e45e6be5f17e0a638acf41e3a6b499aa410127a",
12
- "version": "0.9.0",
10
+ "ref": "v0.11.1",
11
+ "commit": "ea7efbea9a6eba6615881103035cf1a0bdec8db8",
12
+ "version": "0.11.1",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:991de28ab45fdcf9ee364ccbaefa9e7b147608b5928aae5d239e478244e078f4",
25
+ "contentHash": "sha256:71ec7cc98186f74e36dc82839fe8dea7b95454bca70f41e8f66cd7f8aaa9aaf7",
26
26
  "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.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.33",
64
+ "ref": "v0.1.35",
65
65
  "commit": null,
66
- "version": "0.1.33",
66
+ "version": "0.1.35",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:3fed1a8d8775dc09826ff7e4edefb10a99827b97dd6cd0fe8345388b7e501a0d",
73
+ "contentHash": "sha256:afeca4f36bf8ce531b78d77f071a7e3ade1944eba31869916d711c9a191d1580",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -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.11.1",
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.11.1"
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.11.1"
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
@@ -80,7 +80,10 @@ each manifest capability is the tool of its name, long work is a job with a stab
80
80
  a question for a person is an elicitation, and every call carries one trace. Read
81
81
  [the interop reference](references/interop.md) before writing an MCP tool; the helpers
82
82
  are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
83
- [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs).
83
+ [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
84
+ service checks each call's delegation token (DEC-0039): read [the delegation reference](references/delegation.md);
85
+ the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
86
+ [`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs).
84
87
 
85
88
  ## Step 2 — the non-negotiables
86
89
 
@@ -0,0 +1,84 @@
1
+ # Delegation — checking who acts for whom (DEC-0039)
2
+
3
+ A service that other agents of the estate call checks each call offline with the kit's verifier: the estate hub
4
+ signs a delegation token naming the person (`sub`), the actor chain (`act`), this service (`aud`), the
5
+ capabilities (`scope`) and the effect ceiling (`effect`); the service checks the signature against the estate's
6
+ published keys and answers `ok` or one refusal code. Contract: `docs/specification/delegation.md` (DL-1…DL-19)
7
+ at the pin. Python: [`scripts/fabric_delegation.py`](../scripts/fabric_delegation.py); Node:
8
+ [`scripts/fabric-delegation.mjs`](../scripts/fabric-delegation.mjs). Both answer the shared vectors exactly as the
9
+ contract's own verifier does.
10
+
11
+ ## Contents
12
+
13
+ - Declaring it
14
+ - Wiring the gate
15
+ - Approvals
16
+ - What a refusal looks like
17
+ - Keys and the cache
18
+ - What the kit does not do
19
+
20
+ ## Declaring it
21
+
22
+ In the well-known document: `surfaces.mcp.auth: "delegation"` (only delegation tokens) and
23
+ `auth.delegation: {issuer, jwks}` — the `iss` you trust (`urn:fabric:estate:<id>`) and where the key set is read.
24
+ The descriptor's token stays the host's: it is never a caller's credential on `/mcp` (DL-12). The probe checks
25
+ the declaration (`auth.delegation-declared`) and that `/mcp` refuses the descriptor token.
26
+
27
+ ## Wiring the gate
28
+
29
+ ```python
30
+ import fabric_delegation as fd, fabric_interop as fi
31
+ keys = fd.JwksCache(well_known["auth"]["delegation"]["jwks"]) # https, loopback http, or a file
32
+ gate = fd.delegation_gate(audience="example-agent", jwks=keys, issuer=well_known["auth"]["delegation"]["issuer"],
33
+ effects={"example.write": "draft", "example.publish": "publish"}) # each tool's manifest effect
34
+ server = fi.McpToolServer("example-agent", VERSION, authorize=gate) # pass the request headers to handle()
35
+ # in a handler: ctx.delegation["principal"], ["caller"], ["chain"] — record the chain with the work
36
+ ```
37
+
38
+ ```js
39
+ import { JwksCache, delegationGate, refusalToolResult } from './fabric-delegation.mjs';
40
+ const gate = delegationGate({ audience: 'example-agent', jwks: new JwksCache(wk.auth.delegation.jwks), issuer: wk.auth.delegation.issuer,
41
+ effects: { 'example.write': 'draft' } });
42
+ const verdict = await gate(params.name, req.headers);
43
+ if (!verdict.ok) return refusalToolResult(verdict);
44
+ ```
45
+
46
+ The token is read from `Fabric-Delegation` first (a forwarded call; present but empty is refused, never skipped),
47
+ then from `Authorization: Bearer` when the bearer is a delegation JWT (DL-9, DL-10). A tool with no declared
48
+ effect is refused (`unknown-state`), never guessed. The checks run in the DL-13 order; nothing about a token is
49
+ believed before its signature. The built-in job tools (`fabric.job.get`, `fabric.job.cancel`) are gated too: by the
50
+ job's own capability and effect, and only for the principal and caller that started the job (another caller is
51
+ `scope-not-granted`); `on_input` gets the checked delegation on its context. Start jobs with `ctx.start_job()` so the
52
+ job records who started it.
53
+
54
+ ## Approvals
55
+
56
+ An action with an external effect may need the operator's approval (DL-17, DL-18). The order is fixed: the token,
57
+ then the grant check where there is one, then `verify_approval` / `verifyApproval`, which consumes the approval's
58
+ `jti` last, then the action. Without an approval, answer `approval-required` with
59
+ `action_digest(action)` / `actionDigest(action)` — the SHA-256 of the action's RFC 8785 form (`jcs`) — so the caller
60
+ can ask the hub. Keep consumed ids in `FileJtiStore` (0600, survives a restart); `MemoryJtiStore` is for tests
61
+ and `UNSAFE_NO_JTI_STORE` exists only by name. No store at all, or a store file that cannot be read, is
62
+ `unknown-state` — never a pass.
63
+
64
+ ## What a refusal looks like
65
+
66
+ DL-19: an MCP tool answers `refusal_tool_result(r)` / `refusalToolResult(r)` — `isError: true` and
67
+ `structuredContent: {error: {code, message, data: {detail}}}`; an HTTP route answers `refusal_http(r)` /
68
+ `refusalHttp(r)` with the code's status (401 for `unauthenticated`, `token-expired`, `principal-unproven`, with
69
+ `WWW-Authenticate: Bearer error="invalid_token"`; 503 for `issuer-unavailable` and `unknown-state`; 403 for the
70
+ rest). `detail` is a short machine word; the token, the approval and the keys never appear in a result or a log.
71
+
72
+ ## Keys and the cache
73
+
74
+ `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
75
+ a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
76
+ `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
77
+ Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
78
+ canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
79
+ itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
80
+
81
+ ## What the kit does not do
82
+
83
+ It does not mint tokens, check attenuation against a parent (the issuer's rule, DL-5), check a grant or a spend
84
+ window (the meter's, DL-21), or decide which actions need an approval — the service decides that.
@@ -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 verify them with `fabric_delegation` / `fabric-delegation.mjs` ([delegation](delegation.md)).
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 verify the tokens: put `delegation_gate` /
19
+ `delegationGate` in front of the tools — see [the delegation reference](delegation.md).
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)