@passioncode-ai/fabric-agent-adapter 0.9.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- 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 +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +84 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -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_service.py +57 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +427 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +92 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +761 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +43 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +130 -34
- 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 +8 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,106 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.1 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
0.11.0 plus one fix. The `v0.11.0` tag was refused by the release's own validation and never published: on Node 22,
|
|
6
|
+
the CI version, the Node kit accepted a forged delegation token signed "under" a small-order key. Node 26's
|
|
7
|
+
`crypto.verify` refuses such keys, but Node 20 and 22 accept them with the same OpenSSL.
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **The Node kit refuses small-order Ed25519 keys itself** (`isSmallOrderKey`: libsodium's list, including the
|
|
12
|
+
non-canonical encodings, compared with the sign bit cleared), so `verifyDelegation` and `verifyApproval` no longer
|
|
13
|
+
depend on the Node version. Checked on Node 20, 22 and 26. The contract's own verifier uses `crypto.verify` the
|
|
14
|
+
same way and has the same exposure on Node before 26; the contract repository was told.
|
|
15
|
+
|
|
16
|
+
## 0.11.0 - 2026-10-12 (tagged, never published; see 0.11.1)
|
|
17
|
+
|
|
18
|
+
Contract DEC-0039, second part (FAA-20): a service built with the kit checks a delegation token and an operator's
|
|
19
|
+
approval offline, in Python and in Node, with the same answers as the contract's own verifier at the pin
|
|
20
|
+
(`8e49fbd`, unchanged).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`scripts/fabric_delegation.py` and `scripts/fabric-delegation.mjs`**: `verify_delegation` / `verifyDelegation`
|
|
25
|
+
(the eleven DL-13 checks in their order), `verify_approval` / `verifyApproval` (DL-18, the jti consumed last),
|
|
26
|
+
`read_delegation_token` / `readDelegationToken` (`Fabric-Delegation` first, then a delegation-typ Bearer),
|
|
27
|
+
`jcs` and `action_digest` / `actionDigest` (RFC 8785), the jti stores (`MemoryJtiStore` / `createMemoryJtiStore`,
|
|
28
|
+
`FileJtiStore`, `UNSAFE_NO_JTI_STORE`), `JwksCache` (DL-15: at most 10 minutes, an unknown `kid` re-read at most
|
|
29
|
+
once a minute, 10 s between failed reads, no redirects), `delegation_gate` / `delegationGate`, and the DL-19
|
|
30
|
+
refusal shapes `refusal_tool_result` / `refusalToolResult` and `refusal_http` / `refusalHttp`.
|
|
31
|
+
- **The Python kit verifies Ed25519 itself** (standard library only): RFC 8032, cofactorless, and as strict as
|
|
32
|
+
OpenSSL. `S` must be below the group order, points must be canonical, and small-order keys are refused.
|
|
33
|
+
- **`McpToolServer(authorize=)`**: the gate runs before every tool; `ctx.delegation` carries the principal, caller
|
|
34
|
+
and chain. A job records who started it, and `fabric.job.get` / `fabric.job.cancel` answer only that principal
|
|
35
|
+
and caller, with a token that still covers the job's capability.
|
|
36
|
+
- **Shared vectors** `test/fixtures/delegation/vectors.json` (66 delegation, 15 approval, 8 header, 5 JCS, 2 digest
|
|
37
|
+
and 330 Ed25519 cases), produced by `make_vectors.mjs`. Every expected answer comes from the contract's verifier
|
|
38
|
+
or from OpenSSL. Both kits' tests run over them.
|
|
39
|
+
- `references/delegation.md`; `surfaces-and-auth.md` and `protocol.md` no longer say the kits do not verify. Scenario
|
|
40
|
+
evals `mcp-auth-delegation` (updated) and `approval-before-an-external-effect` came first.
|
|
41
|
+
|
|
42
|
+
### Security
|
|
43
|
+
|
|
44
|
+
- An independent review of the port found, before release:
|
|
45
|
+
- The Python Ed25519 verifier accepted forged signatures under a small-order key.
|
|
46
|
+
- A nested token with a duplicate member crashed the gate on Python 3.9–3.11.
|
|
47
|
+
- An unreadable jti file allowed approvals to be replayed.
|
|
48
|
+
- The key-set fetch followed redirects.
|
|
49
|
+
- The job tools were ungated.
|
|
50
|
+
|
|
51
|
+
All are fixed, and each has a test that failed first. The small-order forgery is now in the shared vectors.
|
|
52
|
+
|
|
53
|
+
## 0.10.0 - 2026-10-11
|
|
54
|
+
|
|
55
|
+
Contract DEC-0039, first part (FAA-20, issue #52): the operator's dashboard session now lives no longer than
|
|
56
|
+
the contract allows, and the server enforces it; the probe treats `surfaces.mcp.auth: "delegation"` like `own`.
|
|
57
|
+
The contract pin moves to Fabric Agent Contract `main` `8e49fbd` (the DEC-0039 merge, contract PR #30). Bundles
|
|
58
|
+
issued under the DEC-0036, DEC-0035, DEC-0033, DEC-0031, DEC-0024, DEC-0021, DEC-0020 and DEC-0019 revisions
|
|
59
|
+
stay valid.
|
|
60
|
+
|
|
61
|
+
### Security
|
|
62
|
+
|
|
63
|
+
- **Operator sessions end on the server (DEC-0039).** Until now `LoginCodes.session_valid` checked only an HMAC
|
|
64
|
+
and the cookie lived 30 days, so a copied cookie stayed valid until the session key was rotated. Now a
|
|
65
|
+
session ends 12 h after its last use, 7 days after the login code was redeemed however often it is used, on
|
|
66
|
+
`logout`, and when the descriptor token rotates. The cookie carries a signed claim (session id, sign-in time,
|
|
67
|
+
token revision); the code store keeps a record per session (last use, logout), so a cookie with no record is
|
|
68
|
+
refused. Both kits, the same behaviour.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- **Every operator signs in once more after the upgrade**: a cookie of the old format is refused. The host does
|
|
73
|
+
it on the page's `401`.
|
|
74
|
+
- **A remote service with `MemoryCodeStore` loses its sessions on a deploy** (it already lost its login codes):
|
|
75
|
+
the store now holds the session records. Pass a durable store to keep them.
|
|
76
|
+
- **`session_cookie_header` / `remote_session_cookie_header`** (and the Node `sessionCookieHeader` /
|
|
77
|
+
`remoteSessionCookieHeader`) default to `Max-Age` 604800 (7 days), not 30 days.
|
|
78
|
+
- **Default contract pin `a83bec6` → `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5`.** `a83bec6` (DEC-0036) joins
|
|
79
|
+
`SUPPORTED_CONTRACT_COMMITS`. The revisions between (DEC-0037 spend policy, DEC-0038 grant windows,
|
|
80
|
+
DEC-0039 delegation, DEC-0040 Fabric's button) add schemas and optional properties only: the provider
|
|
81
|
+
manifest gains optional cost presets, the governance grant's `effects` may be omitted by a delegation grant,
|
|
82
|
+
and the well-known document gains `auth.delegation` and the `delegation` value of `surfaces.mcp.auth`.
|
|
83
|
+
Every descriptor, well-known document and bundle the kits wrote before stays valid.
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- **`LoginCodes(..., token=, idle_seconds=, max_seconds=, clock=)`** (`{ token, idleSeconds, maxSeconds, clock }`
|
|
88
|
+
in Node): `token` is the descriptor token or a callable returning the current one; the bounds may be
|
|
89
|
+
shortened, and longer values are clamped. **`logout(cookie)`** and **`expired_session_cookie_header(remote=)`**
|
|
90
|
+
(`logout`, `expiredSessionCookieHeader`); `OPERATOR_SESSION_IDLE_SECONDS`, `OPERATOR_SESSION_MAX_SECONDS`.
|
|
91
|
+
- **`check_service.py`**: `surfaces.mcp.auth: "delegation"` is checked like `own` — the MCP surface must refuse
|
|
92
|
+
the descriptor token, and the caller rules are `NOT_RUN` (DL-16); rule **`auth.delegation-declared`** (with
|
|
93
|
+
`delegation`, the well-known document names `auth.delegation {issuer, jwks}`); rule
|
|
94
|
+
**`login.session-lifetime`** (the cookie set at sign-in does not outlive 7 days).
|
|
95
|
+
- **The sample services** pass `token=` and serve `POST /logout` (a browser write: it needs `X-Fabric-Request`).
|
|
96
|
+
- `references/surfaces-and-auth.md`, `remote-placement.md` and `protocol.md` describe the lifetime, logout and
|
|
97
|
+
DL-16. Scenario evals `operator-session-lifetime` and `mcp-auth-delegation` came first.
|
|
98
|
+
|
|
99
|
+
### Not yet
|
|
100
|
+
|
|
101
|
+
- Verifying delegation tokens and approvals in the kits (`verifyDelegation` / `verifyApproval`) and Fabric's
|
|
102
|
+
button in the dashboard kit (FB-7) follow in a later release (FAA-20 stays open).
|
|
103
|
+
|
|
3
104
|
## 0.9.0 - 2026-10-11
|
|
4
105
|
|
|
5
106
|
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: `
|
|
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.
|
|
233
|
-
released); 0.
|
|
232
|
+
This source checkout is 0.11.1. 0.10.0 is the latest published GitHub/npm release (2026-10-11; 0.7.0 and 0.11.0
|
|
233
|
+
were never released); 0.11.1 is published by its `v0.11.1` 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.
|
|
3
|
+
"version": "0.11.1",
|
|
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.
|
|
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.
|
|
8
|
+
version: "0.11.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
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 `
|
|
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
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -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 = "
|
|
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.
|
|
19
|
+
version: "0.11.1"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
|
-
extension-commit: "
|
|
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 `
|
|
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
|
|
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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md
CHANGED
|
@@ -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.
|
|
54
|
-
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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:
|
|
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
|
|
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)
|