@passioncode-ai/fabric-agent-adapter 0.23.0 → 0.25.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 +82 -0
- package/README.md +1 -1
- 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 +17 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +19 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
Identifier equality by normal form (contract DEC-0076) in both kits, and the contract pin moves from `fe55fc6` to
|
|
6
|
+
contract `main` `a046f57` (FAA-39). `fe55fc6` joins the supported revisions. DEC-0077 (`com.directory` pages) is in the
|
|
7
|
+
pin. Its consumer is the kits' board client, which is not built yet (FAA-12b), so nothing reads it.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`normal_identifier` / `normalIdentifier` and `same_identifier` / `sameIdentifier`** (`fabric_identifiers.py`,
|
|
12
|
+
byte-identical in both skills, and `fabric-identifiers.mjs`). They port the contract's `src/identifiers.ts` and are
|
|
13
|
+
held to its `identifiers.json` (33 normal forms, 8 same, 8 different, 6 lax-port mistakes).
|
|
14
|
+
- A URI follows RFC 3986 §6.2.2.
|
|
15
|
+
- A `urn:` follows RFC 8141 §3.1 (the NSS's case is kept and nothing is decoded).
|
|
16
|
+
- Any other string is its own normal form.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Compared by normal form:**
|
|
21
|
+
- FAC-SEM-030, FAC-SEM-032 and FAC-SEM-034 compare a revision reference's id and the project and capability of
|
|
22
|
+
admissions, bindings and events;
|
|
23
|
+
- FAC-SEM-076 compares share grants and `grant` revocations;
|
|
24
|
+
- FAC-SEM-014 compares a provider entry's `providerId`.
|
|
25
|
+
|
|
26
|
+
Two spellings of one identifier now read as one, and two identifiers stay two.
|
|
27
|
+
- **Written in normal form:** the scaffolded `provider.id`, `identity.subject` and capability `id`, and the `providerId`
|
|
28
|
+
that `write_provider_entry` stores.
|
|
29
|
+
|
|
30
|
+
### Evidence
|
|
31
|
+
|
|
32
|
+
- The route and estate generators now add respelled-id cases (`urn` and the NID upper-cased, and the NSS's case
|
|
33
|
+
changed), each answered by the contract's own functions. The contract's fixtures are in normal form, so without these
|
|
34
|
+
cases the kits' byte comparisons stayed green.
|
|
35
|
+
- The compiled-schema revision tests pass at `2ce3922` → `a046f57`.
|
|
36
|
+
- 20 planted defects, all caught.
|
|
37
|
+
- The scenario eval `identifiers-by-normal-form` was written before the prose.
|
|
38
|
+
|
|
39
|
+
## 0.24.0 - 2026-10-13
|
|
40
|
+
|
|
41
|
+
An app's own descriptor (contract DEC-0069) and the descriptor for listings (DEC-0061) in both kits (FAA-34). The contract
|
|
42
|
+
pin does not move: both decisions are at `fe55fc6`.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **`lifecycle.manager` `node` and `person`.** `node`: Project Observatory's node supervises the service as its child,
|
|
47
|
+
with no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it. `person`: an app a person
|
|
48
|
+
installs and starts — `lifecycle.app` is required, `origin` is optional (its live origin is in the app's own liveness
|
|
49
|
+
file), and `installedBy` is `{by: "person", role, tool}`. `write_descriptor` / `writeDescriptor` claim no port for
|
|
50
|
+
it, and `check_service.py` reads it as installed, never running: `descriptor.app-installed`, no port claim, and the
|
|
51
|
+
well-known document `NOT_RUN`.
|
|
52
|
+
- **`installedBy` by role, `runner` and `peer`.**
|
|
53
|
+
- `installedBy` is a string or `{by, role?, tool}`.
|
|
54
|
+
- `runner` is `{kind?, credentialKind}`, a kind and never a credential.
|
|
55
|
+
- `peer` is `{endpoint, custody, residency, maxDataClass}`. An offer needs `runner` and never a subscription.
|
|
56
|
+
- FAC-SEM-085 refuses a peer on a reserved name. FAC-SEM-086 refuses a value shaped like a credential in `runner` or
|
|
57
|
+
`installedBy`, naming its place.
|
|
58
|
+
- **Ports held to the contract's vectors:** `peer_refusal` / `peerRefusal` (`service-peer.json`),
|
|
59
|
+
`app_descriptor_action` / `appDescriptorAction` and `app_descriptor_stale` / `appDescriptorStale`
|
|
60
|
+
(`app-descriptor.json`).
|
|
61
|
+
- **`fabric_button_presence` / `fabricButtonPresence`** answer whether Fabric's button may show. The answer is
|
|
62
|
+
`present` only while Fabric's own descriptor (`fabric.default.json`) is registered and, for an app's descriptor,
|
|
63
|
+
its app is still installed. A server sets `data-fabric="absent"` otherwise.
|
|
64
|
+
- **Usage:** `make_usage_receipt` / `makeUsageReceipt` accept `route: "platform-metered"`. A configuration's route
|
|
65
|
+
stays `subscription` or `key`.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- `register_remote` / `registerRemote` and the sample's `register` write `installedBy` in its object form
|
|
70
|
+
(`{by: "person", role: "operator", tool}`). `register_remote` / `registerRemote` also take `runner` and `peer`.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- `check_service.py` reported two descriptors without an origin as claiming the same port.
|
|
75
|
+
- `write_descriptor` / `writeDescriptor` failed on a descriptor without one.
|
|
76
|
+
|
|
77
|
+
### Evidence
|
|
78
|
+
|
|
79
|
+
- Every service-descriptor fixture of the contract at `fe55fc6` is held through both validators: 10 valid accepted;
|
|
80
|
+
every invalid one refused except the six local-channel negatives (DEC-0075, not a kit surface), a set the tests pin.
|
|
81
|
+
- The FAC-SEM-085/086 semantic fixtures answer at the contract's places.
|
|
82
|
+
- 32 planted defects, all caught.
|
|
83
|
+
- The scenario eval `app-descriptor-and-listing` was written before the prose.
|
|
84
|
+
|
|
3
85
|
## 0.23.0 - 2026-10-13
|
|
4
86
|
|
|
5
87
|
The contract pin moves from `b291bf0` (DEC-0055) to contract `main` `fe55fc6`, and both kits follow every rule in that
|
package/README.md
CHANGED
|
@@ -235,7 +235,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
235
235
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
236
236
|
|
|
237
237
|
- version: `0.1.0`
|
|
238
|
-
- commit: `
|
|
238
|
+
- commit: `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
|
|
239
239
|
|
|
240
240
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
241
241
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.25.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.
|
|
8
|
+
version: "0.25.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
|
|
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 `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`.
|
|
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
|
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
|
|
|
21
21
|
|
|
22
22
|
CONTRACT_VERSION = "0.1.0"
|
|
23
23
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
24
|
-
CONTRACT_COMMIT = "
|
|
24
|
+
CONTRACT_COMMIT = "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
|
|
25
25
|
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
26
26
|
# Only this declaration may name a legacy revision in live files.
|
|
27
|
-
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
27
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
28
28
|
# #endregion supported-contract-revisions
|
|
29
29
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
30
30
|
MCP_REVISION = "2026-07-28"
|
|
@@ -47,6 +47,15 @@ class AdaptationError(Exception):
|
|
|
47
47
|
"""Expected user-facing adaptation error."""
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
|
|
51
|
+
def _normal_identifier(value: str) -> str:
|
|
52
|
+
"""DEC-0076's normal form, from fabric_identifiers.py beside this file (byte-identical to the service kit's)."""
|
|
53
|
+
import importlib.util
|
|
54
|
+
spec = importlib.util.spec_from_file_location("fabric_identifiers", Path(__file__).resolve().parent / "fabric_identifiers.py")
|
|
55
|
+
module = importlib.util.module_from_spec(spec)
|
|
56
|
+
spec.loader.exec_module(module)
|
|
57
|
+
return module.normal_identifier(value)
|
|
58
|
+
|
|
50
59
|
def _read_text(path: Path, limit: int = 262_144) -> str:
|
|
51
60
|
try:
|
|
52
61
|
if path.stat().st_size > limit:
|
|
@@ -237,24 +246,27 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
|
|
|
237
246
|
raise AdaptationError("a runtime argument carries a credential; argv is visible to every process (LC-21): read it from a secret reference instead")
|
|
238
247
|
|
|
239
248
|
created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
249
|
+
# DEC-0076: a writer emits identifiers in their normal form, so every reader finds them under one spelling.
|
|
250
|
+
provider_id = _normal_identifier(args.provider_id)
|
|
251
|
+
capability_id = _normal_identifier(args.capability_id)
|
|
240
252
|
manifest = {
|
|
241
253
|
"contractVersion": CONTRACT_VERSION,
|
|
242
254
|
"provider": {
|
|
243
|
-
"id":
|
|
255
|
+
"id": provider_id,
|
|
244
256
|
"revision": 1,
|
|
245
257
|
"contentHash": "sha256:" + ("0" * 64),
|
|
246
258
|
"createdAt": created_at,
|
|
247
259
|
"createdBy": "urn:fabric:adapter:author",
|
|
248
260
|
"name": args.provider_name,
|
|
249
261
|
"identity": {
|
|
250
|
-
"subject":
|
|
262
|
+
"subject": provider_id,
|
|
251
263
|
"method": "local-install" if args.profile == "local-runner" or (args.profile == "mcp" and args.mcp_mode == "stdio") else "tls",
|
|
252
264
|
},
|
|
253
265
|
"supportedContractVersions": [CONTRACT_VERSION],
|
|
254
266
|
},
|
|
255
267
|
"capabilities": [
|
|
256
268
|
{
|
|
257
|
-
"id":
|
|
269
|
+
"id": capability_id,
|
|
258
270
|
"name": args.capability_name,
|
|
259
271
|
"inputSchema": _schema_uri(args.schema_base, "schemas/capability-input.schema.json"),
|
|
260
272
|
"outputSchema": _schema_uri(args.schema_base, "schemas/capability-output.schema.json"),
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal.
|
|
2
|
+
|
|
3
|
+
A port of the contract's `src/identifiers.ts`, held to its `fixtures/vectors/identifiers.json`. The normal form of a URI
|
|
4
|
+
is RFC 3986 §6.2.2 syntax-based normalization: the scheme and the host lower-cased and percent-encoding hex upper-cased
|
|
5
|
+
(§6.2.2.1), percent-encoded unreserved characters decoded (§6.2.2.2), dot segments removed from a hierarchical path
|
|
6
|
+
(§6.2.2.3), and only the leading ones from a rootless path; nothing scheme-based (§6.2.3). A `urn:` URI follows RFC 8141
|
|
7
|
+
§3.1 instead: the scheme and the NID lower-cased, percent-encoding hex upper-cased, nothing decoded, the NSS's case kept,
|
|
8
|
+
the r-, q- and f-components ignored. A string that is not a URI by RFC 3986's `URI` production is its own normal form.
|
|
9
|
+
Nothing here raises. Standard library only; this file is byte-identical in both skills' `scripts/`."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
_UNRESERVED = r"A-Za-z0-9\-._~"
|
|
16
|
+
_SUB_DELIMS = r"!$&'()*+,;="
|
|
17
|
+
_PCT = r"%[0-9A-Fa-f]{2}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run(chars: str) -> "re.Pattern[str]":
|
|
21
|
+
"""`*( <chars> / pct-encoded )`, linear: one character class beside one fixed triplet."""
|
|
22
|
+
return re.compile(r"(?:[%s]|%s)*" % (chars, _PCT), re.ASCII)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_USERINFO = _run(_UNRESERVED + _SUB_DELIMS + ":")
|
|
26
|
+
_REG_NAME = _run(_UNRESERVED + _SUB_DELIMS)
|
|
27
|
+
_PATH = _run(_UNRESERVED + _SUB_DELIMS + ":@/")
|
|
28
|
+
_QUERY = _run(_UNRESERVED + _SUB_DELIMS + ":@/?")
|
|
29
|
+
_PORT = re.compile(r"[0-9]*", re.ASCII)
|
|
30
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
|
|
31
|
+
|
|
32
|
+
# RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
|
|
33
|
+
_H16 = "[0-9A-Fa-f]{1,4}"
|
|
34
|
+
_DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
|
|
35
|
+
_IPV4 = r"%s(?:\.%s){3}" % (_DEC_OCTET, _DEC_OCTET)
|
|
36
|
+
_LS32 = "(?:%s:%s|%s)" % (_H16, _H16, _IPV4)
|
|
37
|
+
_IPV6 = "|".join([
|
|
38
|
+
"(?:%s:){6}%s" % (_H16, _LS32),
|
|
39
|
+
"::(?:%s:){5}%s" % (_H16, _LS32),
|
|
40
|
+
"(?:%s)?::(?:%s:){4}%s" % (_H16, _H16, _LS32),
|
|
41
|
+
"(?:(?:%s:){0,1}%s)?::(?:%s:){3}%s" % (_H16, _H16, _H16, _LS32),
|
|
42
|
+
"(?:(?:%s:){0,2}%s)?::(?:%s:){2}%s" % (_H16, _H16, _H16, _LS32),
|
|
43
|
+
"(?:(?:%s:){0,3}%s)?::%s:%s" % (_H16, _H16, _H16, _LS32),
|
|
44
|
+
"(?:(?:%s:){0,4}%s)?::%s" % (_H16, _H16, _LS32),
|
|
45
|
+
"(?:(?:%s:){0,5}%s)?::%s" % (_H16, _H16, _H16),
|
|
46
|
+
"(?:(?:%s:){0,6}%s)?::" % (_H16, _H16),
|
|
47
|
+
])
|
|
48
|
+
_IP_LITERAL = re.compile(r"\[(?:%s|[Vv][0-9A-Fa-f]+\.[%s%s:]+)\]" % (_IPV6, _UNRESERVED, _SUB_DELIMS), re.ASCII)
|
|
49
|
+
|
|
50
|
+
# RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
|
|
51
|
+
_URN_NAME = re.compile(r"([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)", re.ASCII | re.DOTALL)
|
|
52
|
+
_SPLIT = re.compile(r"([^:/?#]+):(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL)
|
|
53
|
+
_TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})")
|
|
54
|
+
_UNRESERVED_CHAR = re.compile(r"[A-Za-z0-9\-._~]", re.ASCII)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _percent(text: str, keep_encoded: bool = False) -> str:
|
|
58
|
+
"""Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless ``keep_encoded``."""
|
|
59
|
+
def one(match: "re.Match[str]") -> str:
|
|
60
|
+
char = chr(int(match.group(1), 16))
|
|
61
|
+
return char if not keep_encoded and _UNRESERVED_CHAR.fullmatch(char) else "%" + match.group(1).upper()
|
|
62
|
+
return _TRIPLET.sub(one, text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _strip_leading_dots(path: str) -> str:
|
|
66
|
+
"""A rootless path loses its leading "./" and "../" segments while what is left stays rootless (DEC-0076)."""
|
|
67
|
+
start = 0
|
|
68
|
+
while path.startswith("./", start) or path.startswith("../", start):
|
|
69
|
+
start += 2 if path.startswith("./", start) else 3
|
|
70
|
+
rest = path[start:]
|
|
71
|
+
return path if rest == "" or rest.startswith("/") else rest
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _remove_dot_segments(path: str) -> str:
|
|
75
|
+
"""RFC 3986 §5.2.4 remove_dot_segments over a path whose "." and ".." are already decoded."""
|
|
76
|
+
absolute = path.startswith("/")
|
|
77
|
+
segments = (path[1:] if absolute else path).split("/")
|
|
78
|
+
output = []
|
|
79
|
+
for index, segment in enumerate(segments):
|
|
80
|
+
last = index == len(segments) - 1
|
|
81
|
+
if segment in (".", ".."):
|
|
82
|
+
if segment == ".." and output:
|
|
83
|
+
output.pop()
|
|
84
|
+
if last: # a dot segment that ends the path leaves it ending in "/"
|
|
85
|
+
output.append("")
|
|
86
|
+
continue
|
|
87
|
+
output.append(segment)
|
|
88
|
+
return ("/" if absolute else "") + "/".join(output)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _authority_parts(authority: str) -> Optional[Tuple[Optional[str], str, Optional[str]]]:
|
|
92
|
+
at = authority.rfind("@")
|
|
93
|
+
userinfo = None if at == -1 else authority[:at]
|
|
94
|
+
host_port = authority if at == -1 else authority[at + 1:]
|
|
95
|
+
bracket = host_port.find("]") if host_port.startswith("[") else -1
|
|
96
|
+
colon = host_port.find(":", 0 if bracket == -1 else bracket)
|
|
97
|
+
host = host_port if colon == -1 else host_port[:colon]
|
|
98
|
+
port = None if colon == -1 else host_port[colon + 1:]
|
|
99
|
+
if userinfo is not None and not _USERINFO.fullmatch(userinfo):
|
|
100
|
+
return None
|
|
101
|
+
if port is not None and not _PORT.fullmatch(port):
|
|
102
|
+
return None
|
|
103
|
+
if not (_IP_LITERAL.fullmatch(host) if host.startswith("[") else _REG_NAME.fullmatch(host)):
|
|
104
|
+
return None
|
|
105
|
+
return userinfo, host, port
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(text: str) -> Optional[Tuple[str, Optional[str], str, Optional[str], Optional[str]]]:
|
|
109
|
+
"""RFC 3986 Appendix B's split, then each component checked against its §3 production; None when one fails."""
|
|
110
|
+
match = _SPLIT.fullmatch(text)
|
|
111
|
+
if not match:
|
|
112
|
+
return None
|
|
113
|
+
scheme, authority, path, query, fragment = match.groups()
|
|
114
|
+
path = path or ""
|
|
115
|
+
if not _SCHEME.fullmatch(scheme) or not _PATH.fullmatch(path):
|
|
116
|
+
return None
|
|
117
|
+
if query is not None and not _QUERY.fullmatch(query):
|
|
118
|
+
return None
|
|
119
|
+
if fragment is not None and not _QUERY.fullmatch(fragment):
|
|
120
|
+
return None
|
|
121
|
+
if authority is not None and _authority_parts(authority) is None:
|
|
122
|
+
return None
|
|
123
|
+
if authority is None and path == "":
|
|
124
|
+
return None
|
|
125
|
+
return scheme, authority, path, query, fragment
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _urn_form(authority: Optional[str], path: str, query: Optional[str]) -> str:
|
|
129
|
+
name = _URN_NAME.fullmatch(path)
|
|
130
|
+
path = "%s:%s" % (name.group(1).lower(), _percent(name.group(2), True)) if name else _percent(path, True)
|
|
131
|
+
# A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
|
|
132
|
+
kept = "?" + _percent(query, True) if query is not None and not re.match(r"[+=]", query) else ""
|
|
133
|
+
auth = "//" + _percent(authority, True) if authority is not None else ""
|
|
134
|
+
return "urn:%s%s%s" % (auth, path, kept)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _generic_form(scheme: str, authority: Optional[str], path: str, query: Optional[str], fragment: Optional[str]) -> str:
|
|
138
|
+
auth = ""
|
|
139
|
+
if authority is not None:
|
|
140
|
+
userinfo, host, port = _authority_parts(authority) # type: ignore[misc]
|
|
141
|
+
# The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
|
|
142
|
+
lowered = re.sub(r"%[0-9a-f]{2}", lambda m: m.group(0).upper(), _percent(host).lower())
|
|
143
|
+
auth = "//%s%s%s" % ("" if userinfo is None else _percent(userinfo) + "@", lowered, "" if port is None else ":" + port)
|
|
144
|
+
decoded = _percent(path)
|
|
145
|
+
hierarchical = authority is not None or decoded.startswith("/")
|
|
146
|
+
out = _remove_dot_segments(decoded) if hierarchical else _strip_leading_dots(decoded)
|
|
147
|
+
if authority is None and out.startswith("//"): # it would read as an authority: keep the decoded path
|
|
148
|
+
out = decoded
|
|
149
|
+
q = "" if query is None else "?" + _percent(query)
|
|
150
|
+
f = "" if fragment is None else "#" + _percent(fragment)
|
|
151
|
+
return "%s:%s%s%s%s" % (scheme.lower(), auth, out, q, f)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def normal_identifier(value: Any) -> Optional[str]:
|
|
155
|
+
"""The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`),
|
|
156
|
+
any other string as it is, None for a value that is not a string. The contract's `normalIdentifier`."""
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
return None
|
|
159
|
+
parts = _parse(value)
|
|
160
|
+
if parts is None:
|
|
161
|
+
return value
|
|
162
|
+
scheme, authority, path, query, fragment = parts
|
|
163
|
+
return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _strict_equal(a: Any, b: Any) -> bool:
|
|
167
|
+
"""JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
|
|
168
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
169
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
170
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
171
|
+
return a == b
|
|
172
|
+
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
173
|
+
return a is b
|
|
174
|
+
return type(a) is type(b) and a == b
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def same_identifier(a: Any, b: Any) -> bool:
|
|
178
|
+
"""Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
|
|
179
|
+
identifier and is compared as it is (JavaScript's ===). The contract's `sameIdentifier`."""
|
|
180
|
+
if not isinstance(a, str) or not isinstance(b, str):
|
|
181
|
+
return _strict_equal(a, b)
|
|
182
|
+
return normal_identifier(a) == normal_identifier(b)
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py
CHANGED
|
@@ -142,7 +142,8 @@ def _run_problems(run: Any) -> List[str]:
|
|
|
142
142
|
|
|
143
143
|
|
|
144
144
|
def manifest_problems(entry: Dict[str, Any]) -> List[str]:
|
|
145
|
-
"""FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId
|
|
145
|
+
"""FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId, compared by normal
|
|
146
|
+
form (DEC-0076)."""
|
|
146
147
|
try:
|
|
147
148
|
manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"))
|
|
148
149
|
except (OSError, ValueError):
|
|
@@ -150,7 +151,8 @@ def manifest_problems(entry: Dict[str, Any]) -> List[str]:
|
|
|
150
151
|
provider = manifest.get("provider") if isinstance(manifest, dict) else None
|
|
151
152
|
if not isinstance(provider, dict) or not isinstance(manifest.get("capabilities"), list):
|
|
152
153
|
return ["%s is not a provider manifest (FAC-SEM-014)" % entry["manifest"]]
|
|
153
|
-
|
|
154
|
+
import fabric_identifiers # noqa: E402 - beside this file; DEC-0076: URIs compared by normal form
|
|
155
|
+
if not fabric_identifiers.same_identifier(provider.get("id"), entry["providerId"]):
|
|
154
156
|
return ["providerId %s is not the manifest's provider.id %s (FAC-SEM-014)" % (entry["providerId"], provider.get("id"))]
|
|
155
157
|
return []
|
|
156
158
|
|
|
@@ -195,6 +197,9 @@ def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None
|
|
|
195
197
|
clash = _service_ids(services or services_dir()).get(entry["id"])
|
|
196
198
|
if clash:
|
|
197
199
|
raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
|
|
200
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
201
|
+
# DEC-0076: written in its normal form, so a reader that looks it up finds one spelling.
|
|
202
|
+
entry = dict(entry, providerId=fabric_identifiers.normal_identifier(entry["providerId"]))
|
|
198
203
|
target = (directory or providers_dir()) / ("%s.json" % entry["id"])
|
|
199
204
|
_atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
|
|
200
205
|
return target
|
|
@@ -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 install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. 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.25.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
|
-
extension-commit: "
|
|
22
|
+
extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Building Fabric services
|
|
@@ -323,8 +323,11 @@ this estate needs.
|
|
|
323
323
|
|
|
324
324
|
## When something is missing
|
|
325
325
|
|
|
326
|
-
- **Not macOS / no launchd:**
|
|
327
|
-
service read-only. Keep every other rule.
|
|
326
|
+
- **Not macOS / no launchd:** the kits write a systemd user unit (Linux) or a per-user Scheduled Task (Windows); with
|
|
327
|
+
no supervisor at all declare `lifecycle.manager: "none"`, and hosts show the service read-only. Keep every other rule.
|
|
328
|
+
- **A desktop app with no supervisor** (DEC-0069): its descriptor says `manager: "person"` with `lifecycle.app`, no
|
|
329
|
+
origin, `installedBy {by: "person", role, tool}`; its live origin is in its own liveness file
|
|
330
|
+
(the lifecycle reference, *An app's own descriptor and the node*).
|
|
328
331
|
- **No Python:** use the Node kit; run the probe from any machine that has Python and
|
|
329
332
|
can reach the port, or mark the probe `NOT_RUN` with the reason.
|
|
330
333
|
- **Neither kit fits the framework:** implement the four routes and the lock by hand
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
CHANGED
|
@@ -100,10 +100,13 @@ service. Load it after the stylesheet, with ids only:
|
|
|
100
100
|
— or one shaped like a credential — means no button, never a bad link. A press opens a view in Fabric and starts
|
|
101
101
|
nothing (FB-8).
|
|
102
102
|
- **The host (FB-6):** when the host's `fabric_button` cookie says `owner=host`, the page hides its own button.
|
|
103
|
-
- **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
- **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"`, or when the page is still
|
|
104
|
+
visible and focused 1.5 s after a link was followed, the menu says "Fabric is not installed — get it at
|
|
105
|
+
passioncode.ai/fabric". Nothing downloads, nothing retries. A local service's server decides `data-fabric` with
|
|
106
|
+
`fabric_button_presence()` / `fabricButtonPresence()`: `present` while Fabric's own descriptor
|
|
107
|
+
(`services/fabric.default.json`, Fabric ADR-0139) is registered and, for an app's descriptor (`manager: "person"`,
|
|
108
|
+
DEC-0069), its app is still installed; `absent` otherwise. A press only follows a link, so the button never reads
|
|
109
|
+
Fabric's liveness file; a caller that sends Fabric anything reads that file and checks the process first.
|
|
107
110
|
|
|
108
111
|
`window.FabricButton` exposes `buildFabricLink`, `parseFabricLink` and `parseButtonCookie` for a page that needs
|
|
109
112
|
them. The button is optional: a page without the script has no button.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -104,6 +104,22 @@ never infers supervision from its parent process.
|
|
|
104
104
|
starts against a held lock are recorded and read as `FAC-SEM-078` reads them
|
|
105
105
|
(`test/*supervisors*`).
|
|
106
106
|
|
|
107
|
+
## An app's own descriptor and the node
|
|
108
|
+
|
|
109
|
+
**An app** a person installs and starts has no supervisor (contract DEC-0069). Its descriptor says
|
|
110
|
+
`"lifecycle": {"manager": "person", "app": "<the installed bundle or executable>"}`, `installedBy {by: "person", role,
|
|
111
|
+
tool}` and no `origin`: it says the app is installed, never that it runs. The installer writes it; where there is none
|
|
112
|
+
(a macOS app dragged into Applications), the app writes it on a start when `app_descriptor_action` /
|
|
113
|
+
`appDescriptorAction` answers `write` — no descriptor yet, another version in `installedBy.tool`, or another path than
|
|
114
|
+
`lifecycle.app` — and never from an App Translocation path. Its live origin and process id go in its own liveness file,
|
|
115
|
+
rewritten on every start and removed on quit; a caller that sends anything reads that file and checks the process.
|
|
116
|
+
`write_descriptor` claims no port for it, and a reader that finds `lifecycle.app` gone treats the descriptor as stale.
|
|
117
|
+
|
|
118
|
+
**The node** (DEC-0061): `"lifecycle": {"manager": "node"}` is a local service Project Observatory's node installed and
|
|
119
|
+
supervises as its child. There is no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it; the
|
|
120
|
+
node starts the child with the supervisor marker (`--fabric-supervisor node` or the variable) and owns Install, Off,
|
|
121
|
+
Code, Uninstall and state.
|
|
122
|
+
|
|
107
123
|
## Directories
|
|
108
124
|
|
|
109
125
|
| What | macOS | Linux | Windows (DEC-0032) |
|
|
@@ -206,7 +222,9 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
|
|
|
206
222
|
A task cannot set an environment variable, so `task_xml` / `taskXml` write the marker as an
|
|
207
223
|
argument, `--fabric-supervisor task-scheduler`, before any `--`: a copy the Task Scheduler starts
|
|
208
224
|
backs off in-process like one under launchd or systemd. `IgnoreNew` keeps the scheduler from
|
|
209
|
-
starting a second copy.
|
|
225
|
+
starting a second copy. Measured (FAA-38, [report](https://github.com/passioncode-ai/fabric-agent-adapter/blob/main/docs/reports/2026-10-13-faa-38-windows-exit75/README.md)):
|
|
226
|
+
`RestartOnFailure` does not restart a task whose action exits 75, so that wait is the only recovery there; a copy
|
|
227
|
+
that still exits 75 stays down until the next logon.
|
|
210
228
|
|
|
211
229
|
**One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
|
|
212
230
|
(Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
7
7
|
canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044; delegation sealed and trust tiers
|
|
8
8
|
DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
|
|
9
9
|
the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
|
|
10
|
-
estate record's 60 s skew DEC-0066
|
|
10
|
+
estate record's 60 s skew DEC-0066; an app's own descriptor DEC-0069; the descriptor for listings DEC-0061; identifier equality
|
|
11
|
+
by normal form DEC-0076). The contract's schemas are normative;
|
|
11
12
|
this page is the working summary. Extension key:
|
|
12
13
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
13
14
|
|
|
@@ -31,13 +32,26 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
31
32
|
| `protocol` | `"fabric-service/0.1"` |
|
|
32
33
|
| `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique per machine |
|
|
33
34
|
| `name`, `summary` | ≤ 80 and ≤ 200 characters |
|
|
34
|
-
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
|
|
35
|
+
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim. Optional for an app's descriptor only (`manager: "person"`, DEC-0069): its live origin and process id are in the app's own liveness file, and a descriptor without an origin claims no port (`FAC-SEM-010`) |
|
|
35
36
|
| `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
|
|
36
|
-
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032)
|
|
37
|
+
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
|
|
37
38
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
38
39
|
| `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
|
|
39
40
|
| `source.repository`, `fabricManifest` | optional |
|
|
40
|
-
| `installedAt`, `installedBy` | when and by
|
|
41
|
+
| `installedAt`, `installedBy` | when, and who installed it: the installer and its version as a string, or `{by, role?, tool}` (DEC-0061) — `by` `person` (with `role` `operator` or `administrator`), `node` or `organization`; `role` for a person only; `tool` the installer and its version. It names a role, never a person. An app's descriptor uses the object form with `by: "person"`. `register_remote` / `registerRemote` and the sample write the object form |
|
|
42
|
+
| `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none` — a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
|
|
43
|
+
| `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed` or `personal`; `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
|
|
44
|
+
|
|
45
|
+
## Identifier equality (DEC-0076)
|
|
46
|
+
|
|
47
|
+
Two identifiers are the same when their normal forms are equal. `normal_identifier` / `normalIdentifier` and
|
|
48
|
+
`same_identifier` / `sameIdentifier` (`fabric_identifiers.py`, `fabric-identifiers.mjs`, ports of the contract's
|
|
49
|
+
`src/identifiers.ts`, held to its `identifiers.json`) apply RFC 3986 §6.2.2 to a URI: scheme and host lower-cased,
|
|
50
|
+
percent-encoding hex upper-cased, unreserved characters decoded, dot segments removed. A `urn:` follows RFC 8141 §3.1
|
|
51
|
+
instead: `urn` and the NID lower-cased, nothing decoded, the NSS's case kept. Any other string is its own normal form,
|
|
52
|
+
and a value that is not a string is compared as it is. The kits compare by it in FAC-SEM-030/032/034 (routes, bindings,
|
|
53
|
+
events), FAC-SEM-076 (estate grants and their revocations) and FAC-SEM-014 (a provider entry's `providerId`), and they
|
|
54
|
+
write the identifiers they scaffold in normal form.
|
|
41
55
|
|
|
42
56
|
## Well-known document — `GET /.well-known/fabric-service`
|
|
43
57
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -189,10 +189,23 @@ class Probe:
|
|
|
189
189
|
continue
|
|
190
190
|
if key == me:
|
|
191
191
|
clashes.append("%s declared again in %s" % (me, path.name))
|
|
192
|
-
|
|
192
|
+
# DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
|
|
193
|
+
elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
|
|
194
|
+
and other.get("origin") == self.d.get("origin")):
|
|
193
195
|
clashes.append("port %d also claimed by %s" % (self.port, key))
|
|
194
|
-
|
|
196
|
+
if self.remote:
|
|
197
|
+
unique = "a remote origin claims no port here"
|
|
198
|
+
elif "origin" not in self.d:
|
|
199
|
+
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
200
|
+
else:
|
|
201
|
+
unique = "port %d is unique" % self.port
|
|
195
202
|
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
203
|
+
if (self.d.get("lifecycle") or {}).get("manager") == "person":
|
|
204
|
+
# DEC-0069: installed, never running — a reader checks the app is still there; liveness is the app's own file.
|
|
205
|
+
present = fs.app_present(self.d)
|
|
206
|
+
self.add("descriptor.app-installed", "PASS" if present else "FAIL",
|
|
207
|
+
"%s is installed" % self.d["lifecycle"].get("app") if present
|
|
208
|
+
else "%s is gone: a reader treats this descriptor as stale" % self.d["lifecycle"].get("app"))
|
|
196
209
|
|
|
197
210
|
# well-known ----------------------------------------------------------------
|
|
198
211
|
def well_known_rules(self) -> None:
|
|
@@ -762,6 +775,10 @@ class Probe:
|
|
|
762
775
|
|
|
763
776
|
def run(self) -> List[Result]:
|
|
764
777
|
self.descriptor_rules()
|
|
778
|
+
if "origin" not in self.d and not self.remote:
|
|
779
|
+
self.add("well-known.answers", "NOT_RUN",
|
|
780
|
+
"an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file (DEC-0069)")
|
|
781
|
+
return self.results
|
|
765
782
|
if not self.port:
|
|
766
783
|
return self.results
|
|
767
784
|
self.well_known_rules()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
14
|
import { jcs } from './fabric-delegation.mjs';
|
|
15
|
+
import { normalIdentifier } from './fabric-identifiers.mjs';
|
|
15
16
|
import { secretValue } from './fabric-secrets.mjs';
|
|
16
17
|
|
|
17
18
|
export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
|
|
@@ -428,12 +429,14 @@ function secretReferences(value, flag) {
|
|
|
428
429
|
}
|
|
429
430
|
|
|
430
431
|
function grantsAndRevocations(value, flag) {
|
|
432
|
+
// A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076).
|
|
433
|
+
const grantKey = (id) => (typeof id === 'string' ? normalIdentifier(id) : id);
|
|
431
434
|
const grantIds = new Set();
|
|
432
435
|
list(value.shareGrants).forEach((grant, index) => {
|
|
433
436
|
if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
|
|
434
437
|
const id = isObject(grant.meta) ? grant.meta.id : undefined;
|
|
435
|
-
if (grantIds.has(id)) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
|
|
436
|
-
grantIds.add(id);
|
|
438
|
+
if (grantIds.has(grantKey(id))) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
|
|
439
|
+
grantIds.add(grantKey(id));
|
|
437
440
|
});
|
|
438
441
|
const machines = list(value.machines);
|
|
439
442
|
// A Map, so a kind such as `constructor` or `__proto__` reads nothing from Object.prototype.
|
|
@@ -448,10 +451,11 @@ function grantsAndRevocations(value, flag) {
|
|
|
448
451
|
}));
|
|
449
452
|
const seen = new Set();
|
|
450
453
|
list(value.revocations).forEach((revocation, index) => {
|
|
451
|
-
const
|
|
454
|
+
const ref = revocation.kind === 'grant' ? grantKey(revocation.ref) : revocation.ref;
|
|
455
|
+
const key = `${String(revocation.kind)} ${String(ref)}`;
|
|
452
456
|
if (seen.has(key)) flag(`/revocations/${index}`, `the ${String(revocation.kind)} ${String(revocation.ref)} is revoked twice`);
|
|
453
457
|
seen.add(key);
|
|
454
|
-
if (listed.get(String(revocation.kind))?.has(
|
|
458
|
+
if (listed.get(String(revocation.kind))?.has(ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${String(revocation.kind)} it revokes: revocation outranks addition`);
|
|
455
459
|
});
|
|
456
460
|
}
|
|
457
461
|
|