@passioncode-ai/fabric-agent-adapter 0.33.0 → 0.34.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 +94 -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/references/profile-selection.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +29 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +123 -41
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +40 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +188 -85
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -4
- 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,99 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.1 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
A review of 0.33.0 and 0.34.0 found the profile and route rules disagreeing with the contract on JSON they never met in
|
|
6
|
+
its vectors. The kits now answer as the contract's TypeScript does on any input. The contract pin is unchanged
|
|
7
|
+
(`d8146cf`).
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **FAC-SEM-059 (`runner-profile`) compared loosely.** Python read a missing member and `null` as the same and `1`
|
|
12
|
+
and `true` as equal: an unknown login with `runnerKind: null`, or an allow-list `true` against a profile `1`, passed.
|
|
13
|
+
Node threw on a login or runner kind whose own `toString` member is data. Both now compare with `===`, show such a
|
|
14
|
+
value as its JSON, and read only the vendor table's own members.
|
|
15
|
+
- **FAC-SEM-060 compared a recorded resolution loosely.** Python's `!=` called `1` and `true` equal; Node's
|
|
16
|
+
`JSON.stringify` comparison called `NaN` and `null` equal. Both now use the contract's `jsonEqual`, where NaN equals
|
|
17
|
+
nothing.
|
|
18
|
+
- **A refusal (`model-classes-empty`) named its policy wrongly on hostile input.** Node threw on an id or revision
|
|
19
|
+
whose own `toString` is data; Python wrote `[object Object]`, read `null` as `undefined`, and did not read a revision
|
|
20
|
+
as `Number()` does (`"0x10"`, `[5]`, `null`). Both now use the contract's `show` and `toNumber`; a revision that is
|
|
21
|
+
not a number is written as null.
|
|
22
|
+
- **Route rules (FAC-SEM-028…034) threw or misread hostile JSON.** Node used `String()` where the contract uses `show`;
|
|
23
|
+
Python wrote `[object Object]`, and compared a probe's runner kind and a drive with `!=` (`1` equal to `true`).
|
|
24
|
+
- **The doctor's `model_usable` raised on a catalogue row whose id is an object** (Python), and threw on a policy id
|
|
25
|
+
whose own `toString` is data (Node).
|
|
26
|
+
- **Python wrote and compared JSON values unlike JavaScript** (found by an independent review of this fix):
|
|
27
|
+
- a lone surrogate (`"\ud800"`, valid JSON) raised `TypeError` wherever a message wrote a value, because
|
|
28
|
+
`_json_stringify` used the RFC 8785 writer that refuses one; it now escapes it as `JSON.stringify` does;
|
|
29
|
+
- members whose keys are array indexes came out in the order written, where JavaScript writes them first and
|
|
30
|
+
ascending, so two equal hostile runner kinds were not a FAC-SEM-028 duplicate;
|
|
31
|
+
- integers past 2**53 compared exactly, where JavaScript compares doubles (`9007199254740993` is
|
|
32
|
+
`9007199254740992`), and integers past the double range raised `OverflowError` where JavaScript reads Infinity;
|
|
33
|
+
- a probe index was written as an exact integer instead of JavaScript's number (`1e+21`).
|
|
34
|
+
These helpers are shared with the estate and identifier kits, whose vectors stay green.
|
|
35
|
+
- **Node looked a rule kind up on inherited members**: `routeProblems("__proto__", …)` threw and `"constructor"`
|
|
36
|
+
answered the value; both now answer `undefined`, as the contract does.
|
|
37
|
+
- **The task board broke the workspace's publication**: rows FAA-28 and FAA-27a split into six cells (an escaped
|
|
38
|
+
`\|`, and a fragment a 0.31.0 edit left behind). Both are fixed, and `test/validate.py` now refuses any board row that
|
|
39
|
+
does not split into the header's columns.
|
|
40
|
+
|
|
41
|
+
### Evidence
|
|
42
|
+
|
|
43
|
+
- 38 new profile cases and 21 new route cases (`make_profile_vectors.mjs`, `make_route_vectors.mjs`), each answered by
|
|
44
|
+
the contract's own rules; cases JavaScript cannot carry as an object (big integers, key order) keep their JSON text.
|
|
45
|
+
Against 0.34.0's kits the first 44 of them fail 27 Python subtests and two Node tests.
|
|
46
|
+
- The reviewer's side-by-side comparison of the contract, the Node kit and the Python kit on 17 hostile inputs: 17 of
|
|
47
|
+
17 identical after the fix (Python 1 of 17 before).
|
|
48
|
+
- Unit tests for the doctor and the rule dispatch on hostile input, in both kits; `BoardRowTests` for the board check.
|
|
49
|
+
- Planted defects in the new code, all caught; two were redundant code, now removed.
|
|
50
|
+
|
|
51
|
+
## 0.34.0 - 2026-10-15
|
|
52
|
+
|
|
53
|
+
The adapter is pinned at contract `d8146cf`. The profile kits now read what a lane reads (DEC-0083; FAA-45, contract
|
|
54
|
+
CT-52): a model profile that reads what members, clients or the public send keeps an open-vendor model on its route,
|
|
55
|
+
so the lane never runs on closed vendors alone, which refuse the content it exists to handle.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **Repin** from `82e1636` to `d8146cf`. Every vendored vector is byte-identical except `profile-vectors.json`.
|
|
60
|
+
`82e1636` stays a supported revision.
|
|
61
|
+
- **`open-vendor` is descriptive** in `resolve_model_profile` / `resolveModelProfile` and in the doctor's `model_usable` /
|
|
62
|
+
`modelUsable`: a project's `modelClasses` never admit or exclude a model by it, and a model whose only class is
|
|
63
|
+
`open-vendor` is admitted on nothing. Both copies of the every-class rule answer the contract's shared
|
|
64
|
+
`model-admission.json` vectors.
|
|
65
|
+
- **FAC-SEM-058 and FAC-SEM-060 are total on hostile input**, as the contract's became in DEC-0083's fix round: an
|
|
66
|
+
`instancePath` names an item by its index as written (a non-object before it no longer shifts it, and every model
|
|
67
|
+
counts at its own index), a duplicate id is what JavaScript's `Set` calls one (`1` and `true`, `null` and absent, are
|
|
68
|
+
two), and an id whose own `toString` member is data is shown as its JSON.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **FAC-SEM-112** on a `profile-resolution`: a profile with `reads: ["user-content"]` has an open-vendor route — with
|
|
73
|
+
`fallback: none` its first model is `open-vendor` in the catalogue, with `between-turns` at least one is — and the
|
|
74
|
+
models the project's policy leaves must still give one, or the finding at `/resolved/models` names the policy.
|
|
75
|
+
`closed_user_content_route` / `closedUserContentRoute` name the models that would run on a closed route.
|
|
76
|
+
- `creating-fabric-agents` asks in its grill which lanes read user content; `profile-selection.md` and the profiles
|
|
77
|
+
reference say how such a lane is declared and routed, and that a content refusal or an empty answer is a failed turn.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- **`make_profile_vectors.mjs` read the contract's newer fixtures vacuously.** Since DEC-0083 a profile fixture wraps
|
|
82
|
+
its document as `{description, kind, expect, input}`; the generator fed the wrapper to the rules, so every expectation
|
|
83
|
+
came out empty and the Node test passed without checking anything. It now unwraps them and stops when a fixture's own
|
|
84
|
+
`expect` disagrees with the contract's answer.
|
|
85
|
+
- `resolve_model_profile` takes `catalogue` and `profiles` as optional, as the contract's resolver does, so an absent
|
|
86
|
+
catalogue resolves instead of raising.
|
|
87
|
+
|
|
88
|
+
### Evidence
|
|
89
|
+
|
|
90
|
+
- Both kits answer 72 rule cases (45 crafted: FAC-SEM-112 edges, the policy's outcome, hostile ids and indexes), 81
|
|
91
|
+
resolutions and 8 admission cases.
|
|
92
|
+
- 51 planted defects (30 in Python, 21 in Node), all caught but two that cannot differ on JSON input: a redundant
|
|
93
|
+
type check, now removed from the code, and the Node message of a duplicate object id, which parsed JSON never
|
|
94
|
+
produces. A unit test covers `1` and `1.0` as one id, a case JSON written by JavaScript cannot hold.
|
|
95
|
+
- Eval scenarios `lanes-that-read-user-content` and `user-content-open-vendor-route`.
|
|
96
|
+
|
|
3
97
|
## 0.33.0 - 2026-10-15
|
|
4
98
|
|
|
5
99
|
The adapter is pinned at contract `82e1636`. The estate kits now read contexts (DEC-0082; FAA-41, the RM-28 pilot): one
|
package/README.md
CHANGED
|
@@ -251,7 +251,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
251
251
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
252
252
|
|
|
253
253
|
- version: `0.1.0`
|
|
254
|
-
- commit: `
|
|
254
|
+
- commit: `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
|
|
255
255
|
|
|
256
256
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
257
257
|
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.34.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; 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.34.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.34.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
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 `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`.
|
|
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
|
|
@@ -57,7 +57,10 @@ Use when Fabric launches an installed process such as a coding CLI. Require:
|
|
|
57
57
|
- project-scoped account selection outside the manifest.
|
|
58
58
|
|
|
59
59
|
The user may choose the project's default terminal and override it per agent. Model choice
|
|
60
|
-
stays inside the selected agent/provider; the Fabric profile remains model-agnostic.
|
|
60
|
+
stays inside the selected agent/provider; the Fabric profile remains model-agnostic. The one
|
|
61
|
+
exception is a lane that reads what members, clients or the public send: its model profile
|
|
62
|
+
declares `reads: ["user-content"]` and has an open-vendor route (contract DEC-0083,
|
|
63
|
+
FAC-SEM-112), whichever models the agent picks.
|
|
61
64
|
|
|
62
65
|
## Reject or defer
|
|
63
66
|
|
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 = "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
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, "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
27
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "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"
|
|
@@ -163,12 +163,19 @@ def normal_identifier(value: Any) -> Optional[str]:
|
|
|
163
163
|
return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def _double(value: Any) -> float:
|
|
167
|
+
try:
|
|
168
|
+
return float(value)
|
|
169
|
+
except OverflowError:
|
|
170
|
+
return float("inf") if value > 0 else float("-inf")
|
|
171
|
+
|
|
172
|
+
|
|
166
173
|
def _strict_equal(a: Any, b: Any) -> bool:
|
|
167
174
|
"""JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
|
|
168
175
|
if isinstance(a, bool) or isinstance(b, bool):
|
|
169
176
|
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
170
177
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
171
|
-
return a == b
|
|
178
|
+
return _double(a) == _double(b) # as JavaScript holds them: 2**53 + 1 is 2**53
|
|
172
179
|
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
173
180
|
return a is b
|
|
174
181
|
return type(a) is type(b) and a == b
|
|
@@ -18,10 +18,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
18
18
|
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.
|
|
19
19
|
metadata:
|
|
20
20
|
author: PassionCode.ai
|
|
21
|
-
version: "0.
|
|
21
|
+
version: "0.34.1"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
|
-
extension-commit: "
|
|
24
|
+
extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
# Building Fabric services
|
|
@@ -11,6 +11,7 @@ Python: [`scripts/fabric_profiles.py`](../scripts/fabric_profiles.py); Node:
|
|
|
11
11
|
|
|
12
12
|
- Resolving the model profile
|
|
13
13
|
- Checking the documents
|
|
14
|
+
- A lane that reads user content
|
|
14
15
|
- Declaring and showing what applies
|
|
15
16
|
- The doctor
|
|
16
17
|
- Runner routes
|
|
@@ -27,7 +28,8 @@ resolution = fp.resolve_model_profile(catalogue=catalogue, profiles=estate_and_a
|
|
|
27
28
|
|
|
28
29
|
The first level that names a profile wins: **request, agent, binding or project, estate default**. The project's
|
|
29
30
|
`modelClasses` narrow the profile's models — a model stays only if every class the catalogue gives it is allowed — and
|
|
30
|
-
never choose one.
|
|
31
|
+
never choose one. `open-vendor` is descriptive (DEC-0083): it is never checked against the policy, and a model whose
|
|
32
|
+
only class is `open-vendor` does not stay. An empty result is `{"refused": "model-classes-empty", "policy": {id, revision}}`: refuse the launch
|
|
31
33
|
and name the policy; never fall back to a model outside it. No level at all is `no-profile`; an id nobody offers is
|
|
32
34
|
`unknown-profile`.
|
|
33
35
|
|
|
@@ -37,7 +39,30 @@ and name the policy; never fall back to a model outside it. No level at all is `
|
|
|
37
39
|
(a model catalogued once; a provider never both ordered and ignored, a base slug covering its variants), a
|
|
38
40
|
`model-profiles` set (an id once), a `runner-profile` (a subscription only in its vendor's runner — Claude in Claude
|
|
39
41
|
Code, Kimi in Kimi Code, ChatGPT in Codex; a key route's allow-list is its own model profile; a key named by reference,
|
|
40
|
-
never by value) and a recorded `profile-resolution` (it equals the order's answer).
|
|
42
|
+
never by value) and a recorded `profile-resolution` (it equals the order's answer). Every rule compares as the contract
|
|
43
|
+
does — `===` (`1` and `true` differ, `null` is not absent) and its `jsonEqual` for a recorded resolution, where NaN
|
|
44
|
+
equals nothing — and never raises on hostile JSON: a value whose own `toString` member is data is shown as its JSON.
|
|
45
|
+
|
|
46
|
+
## A lane that reads user content
|
|
47
|
+
|
|
48
|
+
A model profile names what the lane it serves reads (DEC-0083, `reads`): `user-content` — what a member, a client or
|
|
49
|
+
the public sends (messages, questions, images, reviews, screenshots, voice); `operator` — only what the estate's own
|
|
50
|
+
operator or its agents write; `web` — pages fetched from the open web. Absent is "not declared", which stays valid.
|
|
51
|
+
|
|
52
|
+
A profile that reads `user-content` has an **open-vendor route** (FAC-SEM-112): with `fallback: none` (or any value the
|
|
53
|
+
contract does not name) its first model is `open-vendor` in the catalogue; with `between-turns` at least one of its
|
|
54
|
+
models is. Closed vendors refuse the content such a lane exists to handle, so a closed route alone skips the worst of
|
|
55
|
+
it. Which models are `open-vendor` is the catalogue writer's fact; the service never keeps a vendor list of its own,
|
|
56
|
+
and a model the catalogue does not hold counts as closed.
|
|
57
|
+
|
|
58
|
+
- `profile_problems("profile-resolution", doc)` / `profileProblems(...)` answer FAC-SEM-112 beside FAC-SEM-060, on each
|
|
59
|
+
profile (`/profiles/<i>/models`) and on the resolution's outcome: when the resolved profile's own route is open but the
|
|
60
|
+
models the project's policy leaves are all closed, the finding is at `/resolved/models` and names the policy.
|
|
61
|
+
- `closed_user_content_route(profile, catalogue)` / `closedUserContentRoute(profile, catalogue)` name the models that
|
|
62
|
+
would run on such a lane when none is open-vendor, or `None`/`undefined` when the route holds.
|
|
63
|
+
- On such a lane a content refusal, or an empty answer to a non-empty request, is a **failed turn**: under
|
|
64
|
+
`between-turns` the runner moves to the next model; it never counts that answer as done.
|
|
65
|
+
- `operator` and `web` carry no model rule in this revision; they let a lane say that it reads no user content.
|
|
41
66
|
|
|
42
67
|
## Declaring and showing what applies
|
|
43
68
|
|
|
@@ -57,7 +82,8 @@ declaration (`configuration.declared`).
|
|
|
57
82
|
and answers whether the runner is installed, which version answers and whether it is signed in. It runs only a bare
|
|
58
83
|
catalogued binary found on `PATH`, only with the catalogued version argv (and `auth` argv), without a shell, within
|
|
59
84
|
the entry's timeout (at most 5 s); an entry outside these rules is refused and nothing runs. `model_usable(model,
|
|
60
|
-
catalogue, policy)` names why a model cannot be used here: not catalogued,
|
|
85
|
+
catalogue, policy)` names why a model cannot be used here: not catalogued, a class the project does not allow, or
|
|
86
|
+
only `open-vendor`, which admits nothing — the same admission the resolution applies.
|
|
61
87
|
|
|
62
88
|
`probe` is the contract's closed probe result for that runner, the one Fabric's walk records (DEC-0026):
|
|
63
89
|
`not-installed` (no catalogued binary on `PATH`), `not-responding` (the version probe did not answer in its bound,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
|
|
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;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
CHANGED
|
@@ -21,12 +21,40 @@ export class ProfileError extends Error {}
|
|
|
21
21
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
22
|
const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
|
|
23
23
|
const strings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
/** The object items of an array with their index as written, so an instancePath names the item even after a non-object. */
|
|
25
|
+
const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
|
|
26
|
+
/** DEC-0083: the model class of a model whose vendor publishes its weights openly — descriptive: a policy admits and
|
|
27
|
+
* excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends. */
|
|
28
|
+
export const OPEN_VENDOR = 'open-vendor';
|
|
29
|
+
export const USER_CONTENT = 'user-content';
|
|
30
|
+
/** The contract's `show`: String(value), or its JSON where String() throws (an object whose own `toString` is data). */
|
|
31
|
+
const show = (value) => {
|
|
32
|
+
if (typeof value === 'string') return value;
|
|
33
|
+
try {
|
|
34
|
+
return String(value);
|
|
35
|
+
} catch {
|
|
36
|
+
return JSON.stringify(value) ?? typeof value;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** The contract's jsonEqual: arrays by item, objects by own member whatever the order, anything else by ===, so 1 and
|
|
40
|
+
* true differ and NaN equals nothing (a JSON.stringify comparison would call NaN and null equal). */
|
|
41
|
+
function jsonEqual(a, b) {
|
|
42
|
+
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
|
|
43
|
+
if (isObject(a) || isObject(b)) {
|
|
44
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
45
|
+
const keys = Object.keys(a);
|
|
46
|
+
return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
|
|
47
|
+
}
|
|
48
|
+
return a === b;
|
|
29
49
|
}
|
|
50
|
+
/** The contract's toNumber: Number(value), or NaN where Number() throws (an object whose own toString is data). */
|
|
51
|
+
const toNumber = (value) => {
|
|
52
|
+
try {
|
|
53
|
+
return Number(value);
|
|
54
|
+
} catch {
|
|
55
|
+
return Number.NaN;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
30
58
|
|
|
31
59
|
/** FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default. */
|
|
32
60
|
export function resolveModelProfile({ catalogue, profiles, choices = {}, policy } = {}) {
|
|
@@ -39,10 +67,15 @@ export function resolveModelProfile({ catalogue, profiles, choices = {}, policy
|
|
|
39
67
|
if (!isObject(policy) || !Array.isArray(policy.modelClasses)) return { from: level, profile: id, models };
|
|
40
68
|
const allowed = new Set(strings(policy.modelClasses));
|
|
41
69
|
const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
|
|
42
|
-
|
|
70
|
+
// The descriptive class open-vendor is not checked (DEC-0083), and a model with no other class does not stay.
|
|
71
|
+
const within = models.filter((model) => {
|
|
72
|
+
const row = rows.get(model);
|
|
73
|
+
const admission = row === undefined ? [] : strings(row.classes).filter((name) => name !== OPEN_VENDOR);
|
|
74
|
+
return admission.length > 0 && admission.every((name) => allowed.has(name));
|
|
75
|
+
});
|
|
43
76
|
if (within.length) return { from: level, profile: id, models: within };
|
|
44
77
|
const meta = isObject(policy.meta) ? policy.meta : {};
|
|
45
|
-
return { refused: 'model-classes-empty', profile: id, policy: { id:
|
|
78
|
+
return { refused: 'model-classes-empty', profile: id, policy: { id: show(meta.id), revision: toNumber(meta.revision) } };
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
const finding = (code, instancePath, message) => ({ code, instancePath, message });
|
|
@@ -50,13 +83,13 @@ const finding = (code, instancePath, message) => ({ code, instancePath, message
|
|
|
50
83
|
function modelCatalogue(value) {
|
|
51
84
|
const findings = [];
|
|
52
85
|
const seen = new Set();
|
|
53
|
-
|
|
54
|
-
if (seen.has(row.id)) findings.push(finding('FAC-SEM-058', `/models/${index}/id`, `model ${
|
|
86
|
+
indexed(value.models).forEach(([row, index]) => {
|
|
87
|
+
if (seen.has(row.id)) findings.push(finding('FAC-SEM-058', `/models/${index}/id`, `model ${show(row.id)} is catalogued more than once`));
|
|
55
88
|
seen.add(row.id);
|
|
56
89
|
const routing = isObject(row.provider) ? row.provider : {};
|
|
57
90
|
const ignored = new Set(strings(routing.ignore));
|
|
58
91
|
for (const slug of strings(routing.order)) {
|
|
59
|
-
if (ignored.has(slug) || ignored.has(slug.split('/')[0])) findings.push(finding('FAC-SEM-058', `/models/${index}/provider`, `model ${
|
|
92
|
+
if (ignored.has(slug) || ignored.has(slug.split('/')[0])) findings.push(finding('FAC-SEM-058', `/models/${index}/provider`, `model ${show(row.id)} both orders and ignores provider ${slug}`));
|
|
60
93
|
}
|
|
61
94
|
});
|
|
62
95
|
return findings;
|
|
@@ -65,8 +98,8 @@ function modelCatalogue(value) {
|
|
|
65
98
|
function modelProfiles(value) {
|
|
66
99
|
const findings = [];
|
|
67
100
|
const seen = new Set();
|
|
68
|
-
|
|
69
|
-
if (seen.has(profile.id)) findings.push(finding('FAC-SEM-058', `/profiles/${index}/id`, `model profile ${
|
|
101
|
+
indexed(value.profiles).forEach(([profile, index]) => {
|
|
102
|
+
if (seen.has(profile.id)) findings.push(finding('FAC-SEM-058', `/profiles/${index}/id`, `model profile ${show(profile.id)} is declared more than once`));
|
|
70
103
|
seen.add(profile.id);
|
|
71
104
|
});
|
|
72
105
|
return findings;
|
|
@@ -75,12 +108,12 @@ function modelProfiles(value) {
|
|
|
75
108
|
function runnerProfile(value) {
|
|
76
109
|
const findings = [];
|
|
77
110
|
if (value.route === 'subscription' && isObject(value.subscription)) {
|
|
78
|
-
const login =
|
|
111
|
+
const login = show(value.subscription.login);
|
|
79
112
|
const runner = Object.hasOwn(SUBSCRIPTION_RUNNER, login) ? SUBSCRIPTION_RUNNER[login] : undefined;
|
|
80
|
-
if (runner !== value.runnerKind) findings.push(finding('FAC-SEM-059', '/runnerKind', `a ${login} subscription runs only in ${runner ?? "its vendor's runner"}, not in ${
|
|
113
|
+
if (runner !== value.runnerKind) findings.push(finding('FAC-SEM-059', '/runnerKind', `a ${login} subscription runs only in ${runner ?? "its vendor's runner"}, not in ${show(value.runnerKind)}; another runner takes the key route`));
|
|
81
114
|
}
|
|
82
115
|
if (value.route === 'key' && isObject(value.key)) {
|
|
83
|
-
if (value.key.modelAllowList !== value.modelProfile) findings.push(finding('FAC-SEM-059', '/key/modelAllowList', `the key admits the models of ${
|
|
116
|
+
if (value.key.modelAllowList !== value.modelProfile) findings.push(finding('FAC-SEM-059', '/key/modelAllowList', `the key admits the models of ${show(value.key.modelAllowList)}, but the profile runs ${show(value.modelProfile)}`));
|
|
84
117
|
if (typeof value.key.ref === 'string' && secretValue(value.key.ref)) findings.push(finding('FAC-SEM-059', '/key/ref', 'the key is named by its secret reference, never by its value'));
|
|
85
118
|
}
|
|
86
119
|
return findings;
|
|
@@ -90,11 +123,11 @@ function profileResolution(value) {
|
|
|
90
123
|
const findings = [];
|
|
91
124
|
const catalogued = new Set(list(isObject(value.catalogue) ? value.catalogue.models : undefined).map((row) => row.id));
|
|
92
125
|
const ids = new Set();
|
|
93
|
-
|
|
94
|
-
if (ids.has(profile.id)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/id`, `profile id ${
|
|
126
|
+
indexed(value.profiles).forEach(([profile, i]) => {
|
|
127
|
+
if (ids.has(profile.id)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/id`, `profile id ${show(profile.id)} is offered more than once to one resolution`));
|
|
95
128
|
ids.add(profile.id);
|
|
96
|
-
|
|
97
|
-
if (!catalogued.has(model)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/models/${j}`, `profile ${
|
|
129
|
+
(Array.isArray(profile.models) ? profile.models : []).forEach((model, j) => {
|
|
130
|
+
if (typeof model === 'string' && !catalogued.has(model)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/models/${j}`, `profile ${show(profile.id)} names ${model}, which the catalogue does not hold`));
|
|
98
131
|
});
|
|
99
132
|
});
|
|
100
133
|
const choices = isObject(value.choices) ? value.choices : {};
|
|
@@ -103,9 +136,53 @@ function profileResolution(value) {
|
|
|
103
136
|
return findings;
|
|
104
137
|
}
|
|
105
138
|
|
|
139
|
+
/**
|
|
140
|
+
* FAC-SEM-112 (DEC-0083) for one profile: the models that run when the lane reads user content and none of them is an
|
|
141
|
+
* open-vendor model, or undefined when the profile does not read user content or its route has one. Under
|
|
142
|
+
* `between-turns` every model may run; under `none`, or any other value (failing closed), only the first. A model the
|
|
143
|
+
* catalogue does not hold, or whose row is not `open-vendor`, counts as closed. Never throws.
|
|
144
|
+
*/
|
|
145
|
+
export function closedUserContentRoute(profile, catalogue) {
|
|
146
|
+
if (!isObject(profile) || !strings(profile.reads).includes(USER_CONTENT)) return undefined;
|
|
147
|
+
const models = Array.isArray(profile.models) ? profile.models : [];
|
|
148
|
+
const route = profile.fallback === 'between-turns' ? models : models.slice(0, 1);
|
|
149
|
+
const open = new Set();
|
|
150
|
+
for (const row of list(isObject(catalogue) ? catalogue.models : undefined)) {
|
|
151
|
+
if (typeof row.id === 'string' && strings(row.classes).includes(OPEN_VENDOR)) open.add(row.id);
|
|
152
|
+
}
|
|
153
|
+
if (route.some((model) => typeof model === 'string' && open.has(model))) return undefined;
|
|
154
|
+
return route.map((model) => show(model));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** FAC-SEM-112: a profile that reads user content has an open-vendor route, so the lane never runs on closed vendors alone. */
|
|
158
|
+
const closedRoute = (fallback, closed) => (fallback === 'between-turns' ? 'none of its models is' : `its first model, ${closed[0] ?? 'none'}, is not`);
|
|
159
|
+
|
|
160
|
+
/** FAC-SEM-112 on the profiles and on the resolution's outcome: the models the policy leaves keep an open-vendor route. */
|
|
161
|
+
function openVendorRoutes(value) {
|
|
162
|
+
const findings = [];
|
|
163
|
+
const profiles = list(value.profiles);
|
|
164
|
+
indexed(value.profiles).forEach(([profile, i]) => {
|
|
165
|
+
const closed = closedUserContentRoute(profile, value.catalogue);
|
|
166
|
+
if (closed === undefined) return;
|
|
167
|
+
findings.push(finding('FAC-SEM-112', `/profiles/${i}/models`, `profile ${show(profile.id)} reads user content, but ${closedRoute(profile.fallback, closed)} open-vendor in the catalogue: the lane would run on closed vendors alone`));
|
|
168
|
+
});
|
|
169
|
+
const choices = isObject(value.choices) ? value.choices : {};
|
|
170
|
+
const outcome = resolveModelProfile({ catalogue: value.catalogue, profiles: value.profiles, choices, policy: value.policy });
|
|
171
|
+
if (!('models' in outcome)) return findings;
|
|
172
|
+
const profile = profiles.find((item) => item.id === outcome.profile);
|
|
173
|
+
if (!profile || closedUserContentRoute(profile, value.catalogue) !== undefined) return findings;
|
|
174
|
+
const closed = closedUserContentRoute({ ...profile, models: outcome.models }, value.catalogue);
|
|
175
|
+
if (closed === undefined) return findings;
|
|
176
|
+
const meta = isObject(value.policy) && isObject(value.policy.meta) ? value.policy.meta : {};
|
|
177
|
+
findings.push(finding('FAC-SEM-112', '/resolved/models', `the policy ${show(meta.id)} leaves profile ${show(profile.id)}'s lane on closed vendors alone: of the models it admits, ${closedRoute(profile.fallback, closed)} open-vendor in the catalogue`));
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
|
180
|
+
|
|
106
181
|
/** The contract's profileRules: findings for a model-catalogue, model-profiles, runner-profile or profile-resolution; else undefined. */
|
|
107
182
|
export function profileProblems(kind, value) {
|
|
108
|
-
|
|
183
|
+
const table = { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': (v) => [...profileResolution(v), ...openVendorRoutes(v)] };
|
|
184
|
+
// Own members only: a kind such as `constructor` or `__proto__` names no rule.
|
|
185
|
+
return typeof kind === 'string' && Object.hasOwn(table, kind) ? table[kind](value) : undefined;
|
|
109
186
|
}
|
|
110
187
|
|
|
111
188
|
// ---- the doctor's checks ----
|
|
@@ -169,12 +246,16 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
|
|
|
169
246
|
|
|
170
247
|
/** Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow. */
|
|
171
248
|
export function modelUsable(model, catalogue, policy) {
|
|
249
|
+
// The last row of an id wins, as the Map the resolver builds; a model that is not a string matches no row.
|
|
172
250
|
const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
|
|
173
|
-
if (!rows.has(model)) return [`model ${model} is not in the model catalogue`];
|
|
251
|
+
if (typeof model !== 'string' || !rows.has(model)) return [`model ${show(model)} is not in the model catalogue`];
|
|
174
252
|
if (isObject(policy) && Array.isArray(policy.modelClasses)) {
|
|
175
253
|
const allowed = new Set(strings(policy.modelClasses));
|
|
176
|
-
const
|
|
177
|
-
|
|
254
|
+
const policyId = show((isObject(policy.meta) ? policy.meta : {}).id);
|
|
255
|
+
const admission = strings(rows.get(model).classes).filter((c) => c !== OPEN_VENDOR);
|
|
256
|
+
if (!admission.length) return [`model ${model} has no class but ${OPEN_VENDOR}, which admits nothing, so policy ${policyId} does not allow it`];
|
|
257
|
+
const outside = admission.filter((c) => !allowed.has(c));
|
|
258
|
+
if (outside.length) return [`model ${model} has class ${outside.join(', ')}, which policy ${policyId} does not allow`];
|
|
178
259
|
}
|
|
179
260
|
return [];
|
|
180
261
|
}
|
|
@@ -212,14 +293,14 @@ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'n
|
|
|
212
293
|
const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
|
|
213
294
|
const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
|
|
214
295
|
// A revision reference compared whole; its id is an identifier, so it enters the key in its normal form (DEC-0076).
|
|
215
|
-
const refKey = (ref) => (isObject(ref) ? `${
|
|
296
|
+
const refKey = (ref) => (isObject(ref) ? `${show(normalIdentifier(ref.id) ?? ref.id)}@${show(ref.revision)}#${show(ref.contentHash)}` : '');
|
|
216
297
|
const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
|
|
217
298
|
|
|
218
299
|
function runnerRoute(value) {
|
|
219
300
|
const findings = [];
|
|
220
301
|
const kinds = new Set();
|
|
221
302
|
routeCandidates(value).forEach((candidate, index) => {
|
|
222
|
-
const kind =
|
|
303
|
+
const kind = show(candidate.runnerKind);
|
|
223
304
|
if (kinds.has(kind)) findings.push(finding('FAC-SEM-028', `/candidates/${index}/runnerKind`, `runner kind ${kind} appears in the route more than once`));
|
|
224
305
|
kinds.add(kind);
|
|
225
306
|
const session = objectAt(candidate, 'session');
|
|
@@ -235,25 +316,25 @@ function routeBundle(value) {
|
|
|
235
316
|
const findings = [];
|
|
236
317
|
routeCandidates(route).forEach((candidate, index) => {
|
|
237
318
|
const key = refKey(candidate.provider);
|
|
238
|
-
if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${
|
|
319
|
+
if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${show(candidate.runnerKind)} names provider ${key}, which is not a revision admitted for ${show(route.capability)}`));
|
|
239
320
|
});
|
|
240
321
|
if (isObject(value.binding)) {
|
|
241
322
|
const binding = value.binding;
|
|
242
323
|
const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
|
|
243
324
|
// DEC-0076: project and capability are identifiers, compared by normal form.
|
|
244
|
-
if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${
|
|
245
|
-
if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${
|
|
246
|
-
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${
|
|
325
|
+
if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${show(binding.project)}, the route for ${show(route.project)}`);
|
|
326
|
+
if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${show(binding.capability)}, the route orders ${show(route.capability)}`);
|
|
327
|
+
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${show(binding.profileKind)}`);
|
|
247
328
|
if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
|
|
248
329
|
if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
|
|
249
330
|
}
|
|
250
331
|
if (Array.isArray(value.catalogue)) {
|
|
251
|
-
const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [
|
|
332
|
+
const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [show(entry.kind), entry]));
|
|
252
333
|
routeCandidates(route).forEach((candidate, index) => {
|
|
253
|
-
const kind =
|
|
334
|
+
const kind = show(candidate.runnerKind);
|
|
254
335
|
const entry = byKind.get(kind);
|
|
255
336
|
if (!entry) { findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/runnerKind`, `runner kind ${kind} has no entry in the host's catalogue`)); return; }
|
|
256
|
-
if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'),
|
|
337
|
+
if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'), show(candidate.drive))) findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/drive`, `the catalogue entry for ${kind} offers no drive ${show(candidate.drive)}`));
|
|
257
338
|
});
|
|
258
339
|
}
|
|
259
340
|
return findings;
|
|
@@ -266,14 +347,14 @@ function routeEvent(value) {
|
|
|
266
347
|
const findings = [];
|
|
267
348
|
const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
|
|
268
349
|
if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
|
|
269
|
-
if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${
|
|
270
|
-
if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${
|
|
350
|
+
if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${show(event.project)}, the route for ${show(route.project)}`);
|
|
351
|
+
if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${show(event.capability)}, the route orders ${show(route.capability)}`);
|
|
271
352
|
const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
|
|
272
353
|
const candidateAt = (ref, where) => {
|
|
273
354
|
const index = indexOf(ref);
|
|
274
355
|
const candidate = index >= 0 ? list[index] : undefined;
|
|
275
356
|
if (!candidate || candidate.runnerKind !== ref.runnerKind || (ref.provider !== undefined && !sameRef(candidate.provider, ref.provider))) {
|
|
276
|
-
wrong(where, `candidate ${
|
|
357
|
+
wrong(where, `candidate ${show(ref.index)} (${show(ref.runnerKind)}) is not that position of the route`);
|
|
277
358
|
return undefined;
|
|
278
359
|
}
|
|
279
360
|
return candidate;
|
|
@@ -281,8 +362,8 @@ function routeEvent(value) {
|
|
|
281
362
|
const probes = Array.isArray(event.probes) ? event.probes.filter(isObject) : [];
|
|
282
363
|
probes.forEach((probe, position) => {
|
|
283
364
|
const candidate = candidateAt(probe, `/probes/${position}`);
|
|
284
|
-
if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${
|
|
285
|
-
if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${
|
|
365
|
+
if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${show(probe.runnerKind)} may spawn, so finding no held session does not pass it over`);
|
|
366
|
+
if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${show(probe.runnerKind)} has spawn: never, so it cannot have failed to spawn`);
|
|
286
367
|
});
|
|
287
368
|
const expectProbes = (count) => {
|
|
288
369
|
const indexes = probes.map(indexOf);
|
|
@@ -300,9 +381,9 @@ function routeEvent(value) {
|
|
|
300
381
|
const chosen = candidateAt(selected, '/selected');
|
|
301
382
|
if (chosen) {
|
|
302
383
|
const session = objectAt(chosen, 'session');
|
|
303
|
-
if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${
|
|
304
|
-
if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${
|
|
305
|
-
if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${
|
|
384
|
+
if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${show(selected.runnerKind)} has attach: never, so the host cannot attach it`);
|
|
385
|
+
if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${show(selected.runnerKind)} has spawn: never, so the host cannot spawn it`);
|
|
386
|
+
if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${show(selected.runnerKind)} through ${show(chosen.drive)}, not ${show(selected.drive)}`);
|
|
306
387
|
if (event.sticky === true) {
|
|
307
388
|
// A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
308
389
|
if (route.recovery === 'reprobe') wrong('/sticky', 'a route with recovery: reprobe walks from the top at every launch');
|
|
@@ -329,5 +410,6 @@ function routeEvent(value) {
|
|
|
329
410
|
*/
|
|
330
411
|
export function routeProblems(kind, value) {
|
|
331
412
|
if (!isObject(value)) return undefined;
|
|
332
|
-
|
|
413
|
+
const table = { 'runner-route': runnerRoute, 'route-bundle': routeBundle, 'route-event': routeEvent };
|
|
414
|
+
return typeof kind === 'string' && Object.hasOwn(table, kind) ? table[kind](value) : undefined;
|
|
333
415
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -90,6 +90,14 @@ def _number(value: Any) -> bool:
|
|
|
90
90
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _double(value: Any) -> float:
|
|
94
|
+
"""A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
|
|
95
|
+
try:
|
|
96
|
+
return float(value)
|
|
97
|
+
except OverflowError:
|
|
98
|
+
return math.inf if value > 0 else -math.inf
|
|
99
|
+
|
|
100
|
+
|
|
93
101
|
def _same(a: Any, b: Any) -> bool:
|
|
94
102
|
"""JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
|
|
95
103
|
if a is _MISSING or b is _MISSING:
|
|
@@ -99,7 +107,7 @@ def _same(a: Any, b: Any) -> bool:
|
|
|
99
107
|
if isinstance(a, bool) or isinstance(b, bool):
|
|
100
108
|
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
101
109
|
if _number(a) and _number(b):
|
|
102
|
-
return a == b
|
|
110
|
+
return _double(a) == _double(b) # 2**53 + 1 is 2**53 in JavaScript
|
|
103
111
|
return type(a) is type(b) and a == b
|
|
104
112
|
|
|
105
113
|
|
|
@@ -137,7 +145,7 @@ def _js_to_number(value: Any) -> float:
|
|
|
137
145
|
radix = _JS_RADIX.fullmatch(text)
|
|
138
146
|
if radix:
|
|
139
147
|
digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
|
|
140
|
-
return
|
|
148
|
+
return _double(int(digits, base))
|
|
141
149
|
return math.nan
|
|
142
150
|
|
|
143
151
|
|
|
@@ -173,10 +181,37 @@ def _strict_string(value: Any) -> str:
|
|
|
173
181
|
return _js_string(value)
|
|
174
182
|
|
|
175
183
|
|
|
184
|
+
_INDEX_KEY = re.compile(r"0|[1-9][0-9]*", re.ASCII)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _js_keys(value: Dict[str, Any]) -> List[str]:
|
|
188
|
+
"""An object's own keys in the order JavaScript lists them: array-index keys ("0" … "4294967294", no leading zero)
|
|
189
|
+
ascending, then the others in the order written."""
|
|
190
|
+
index = sorted((k for k in value if _INDEX_KEY.fullmatch(k) and int(k) <= 2 ** 32 - 2), key=int)
|
|
191
|
+
taken = set(index)
|
|
192
|
+
return index + [k for k in value if k not in taken]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _js_json_string(text: str) -> str:
|
|
196
|
+
"""JSON.stringify of a string (well-formed, ES2019): a lone surrogate is escaped as \\uXXXX, never refused."""
|
|
197
|
+
out = ['"']
|
|
198
|
+
for ch in text:
|
|
199
|
+
code = ord(ch)
|
|
200
|
+
if ch in _delegation._ESCAPES:
|
|
201
|
+
out.append(_delegation._ESCAPES[ch])
|
|
202
|
+
elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
|
|
203
|
+
out.append("\\u%04x" % code)
|
|
204
|
+
else:
|
|
205
|
+
out.append(ch)
|
|
206
|
+
out.append('"')
|
|
207
|
+
return "".join(out)
|
|
208
|
+
|
|
209
|
+
|
|
176
210
|
def _json_stringify(value: Any) -> str:
|
|
177
|
-
"""JSON.stringify for a parsed JSON value: members in
|
|
211
|
+
"""JSON.stringify for a parsed JSON value: members in JavaScript's key order, numbers as JavaScript writes them, a
|
|
212
|
+
lone surrogate escaped."""
|
|
178
213
|
if isinstance(value, dict):
|
|
179
|
-
return "{" + ",".join(
|
|
214
|
+
return "{" + ",".join(_js_json_string(k) + ":" + _json_stringify(value[k]) for k in _js_keys(value)) + "}"
|
|
180
215
|
if isinstance(value, list):
|
|
181
216
|
return "[" + ",".join(_json_stringify(v) for v in value) + "]"
|
|
182
217
|
if value is None:
|
|
@@ -186,7 +221,7 @@ def _json_stringify(value: Any) -> str:
|
|
|
186
221
|
if _number(value):
|
|
187
222
|
text = _js_string(value)
|
|
188
223
|
return "null" if text in ("NaN", "Infinity", "-Infinity") else text
|
|
189
|
-
return
|
|
224
|
+
return _js_json_string(value)
|
|
190
225
|
|
|
191
226
|
|
|
192
227
|
def _show(value: Any) -> str:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py
CHANGED
|
@@ -163,12 +163,19 @@ def normal_identifier(value: Any) -> Optional[str]:
|
|
|
163
163
|
return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def _double(value: Any) -> float:
|
|
167
|
+
try:
|
|
168
|
+
return float(value)
|
|
169
|
+
except OverflowError:
|
|
170
|
+
return float("inf") if value > 0 else float("-inf")
|
|
171
|
+
|
|
172
|
+
|
|
166
173
|
def _strict_equal(a: Any, b: Any) -> bool:
|
|
167
174
|
"""JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
|
|
168
175
|
if isinstance(a, bool) or isinstance(b, bool):
|
|
169
176
|
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
170
177
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
171
|
-
return a == b
|
|
178
|
+
return _double(a) == _double(b) # as JavaScript holds them: 2**53 + 1 is 2**53
|
|
172
179
|
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
173
180
|
return a is b
|
|
174
181
|
return type(a) is type(b) and a == b
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -19,6 +19,7 @@ import shutil
|
|
|
19
19
|
import subprocess
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
21
21
|
|
|
22
|
+
import fabric_estate as _estate
|
|
22
23
|
import fabric_identifiers as _ids
|
|
23
24
|
import fabric_secrets as _secrets
|
|
24
25
|
import fabric_service as _fs
|
|
@@ -27,6 +28,10 @@ RESOLUTION_ORDER = ("request", "agent", "binding", "estate")
|
|
|
27
28
|
# The vendor login each subscription is, and the one runner kind it runs in (DEC-0046, ADR-0147 decision 2).
|
|
28
29
|
SUBSCRIPTION_RUNNER = {"claude": "claude-code", "kimi": "kimi-code", "chatgpt": "codex"}
|
|
29
30
|
MAX_PROBE_MS = 5000
|
|
31
|
+
#: DEC-0083: the model class of a model whose vendor publishes its weights openly — descriptive: a policy admits and
|
|
32
|
+
#: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
|
|
33
|
+
OPEN_VENDOR = "open-vendor"
|
|
34
|
+
USER_CONTENT = "user-content"
|
|
30
35
|
_BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
31
36
|
|
|
32
37
|
|
|
@@ -38,47 +43,77 @@ def _list(value: Any) -> List[Dict[str, Any]]:
|
|
|
38
43
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
39
44
|
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
_MISSING_KEY = _estate._MISSING
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _at(value: Any, key: str) -> Any:
|
|
50
|
+
"""value[key] as JavaScript reads a member: absent is distinct from null (``_MISSING_KEY``)."""
|
|
51
|
+
return _estate._get(value, key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _json_equal(a: Any, b: Any) -> bool:
|
|
55
|
+
"""The contract's jsonEqual on parsed JSON: arrays by item, objects by member whatever the order, anything else by
|
|
56
|
+
===, so 1 and true differ and NaN equals nothing."""
|
|
57
|
+
if isinstance(a, list) or isinstance(b, list):
|
|
58
|
+
return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
|
|
59
|
+
if isinstance(a, dict) or isinstance(b, dict):
|
|
60
|
+
return (isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b)
|
|
61
|
+
and all(k in b and _json_equal(v, b[k]) for k, v in a.items()))
|
|
62
|
+
return _estate._same(a, b) # NaN != NaN in Python too
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _without_nan(value: Any) -> Any:
|
|
66
|
+
"""A resolution as JSON writes it: NaN and an infinity become null."""
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
return {k: _without_nan(v) for k, v in value.items()}
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [_without_nan(v) for v in value]
|
|
71
|
+
return None if isinstance(value, float) and not math.isfinite(value) else value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _indexed(value: Any) -> List[tuple]:
|
|
75
|
+
"""The object items of an array with their index as written, so an instancePath names the item even after a
|
|
76
|
+
non-object (the contract's ``indexed``)."""
|
|
77
|
+
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
43
78
|
|
|
44
79
|
|
|
45
|
-
def
|
|
46
|
-
"""JavaScript's
|
|
80
|
+
def _set_key(value: Any) -> Any:
|
|
81
|
+
"""A key with JavaScript's Set semantics for a JSON value: 1 and true, null and absent, are different; two objects
|
|
82
|
+
are never the same member however equal."""
|
|
83
|
+
if value is _MISSING_ID:
|
|
84
|
+
return ("undefined",)
|
|
47
85
|
if value is None:
|
|
48
|
-
return "
|
|
49
|
-
if value is True:
|
|
50
|
-
return "true"
|
|
51
|
-
if value is False:
|
|
52
|
-
return "false"
|
|
53
|
-
if isinstance(value, float) and value.is_integer():
|
|
54
|
-
return str(int(value))
|
|
55
|
-
if isinstance(value, (dict, list)):
|
|
56
|
-
return "[object Object]" if isinstance(value, dict) else ",".join(_js_string(v) for v in value)
|
|
57
|
-
return str(value)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _js_number(value: Any) -> Optional[float]:
|
|
61
|
-
"""JavaScript's Number(value); NaN is None (JSON writes it as null)."""
|
|
86
|
+
return ("null",)
|
|
62
87
|
if isinstance(value, bool):
|
|
63
|
-
return
|
|
88
|
+
return ("boolean", value)
|
|
64
89
|
if isinstance(value, (int, float)):
|
|
65
|
-
return
|
|
90
|
+
return ("number", _estate._double(value))
|
|
66
91
|
if isinstance(value, str):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return
|
|
92
|
+
return ("string", value)
|
|
93
|
+
return ("object", id(value))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_MISSING_ID = object()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _id_of(item: Dict[str, Any]) -> Any:
|
|
100
|
+
return item.get("id", _MISSING_ID)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strings(value: Any) -> List[str]:
|
|
104
|
+
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
|
76
105
|
|
|
77
106
|
|
|
78
|
-
def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy: Any = None) -> Dict[str, Any]:
|
|
107
|
+
def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
|
|
108
|
+
"""FAC-SEM-060, as JSON writes the contract's answer (a policy revision that is not a number is null)."""
|
|
109
|
+
return _without_nan(_resolve(catalogue=catalogue, profiles=profiles, choices=choices, policy=policy))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
|
|
79
113
|
"""FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
|
|
80
114
|
When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
|
|
81
|
-
allowed;
|
|
115
|
+
allowed; the descriptive class `open-vendor` is not checked (DEC-0083), and a model with no other class does not stay.
|
|
116
|
+
An empty result refuses the launch, naming the policy."""
|
|
82
117
|
choices = choices if isinstance(choices, dict) else {}
|
|
83
118
|
level = next((name for name in RESOLUTION_ORDER if isinstance(choices.get(name), str)), None)
|
|
84
119
|
if level is None:
|
|
@@ -94,11 +129,16 @@ def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy
|
|
|
94
129
|
rows = {}
|
|
95
130
|
for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None):
|
|
96
131
|
rows[json.dumps(row.get("id"))] = row
|
|
97
|
-
|
|
132
|
+
def admitted(model: str) -> bool:
|
|
133
|
+
row = rows.get(json.dumps(model))
|
|
134
|
+
admission = [c for c in _strings(row.get("classes")) if c != OPEN_VENDOR] if row is not None else []
|
|
135
|
+
return bool(admission) and all(c in allowed for c in admission)
|
|
136
|
+
within = [m for m in models if admitted(m)]
|
|
98
137
|
if within:
|
|
99
138
|
return {"from": level, "profile": profile_id, "models": within}
|
|
100
139
|
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
101
|
-
return {"refused": "model-classes-empty", "profile": profile_id,
|
|
140
|
+
return {"refused": "model-classes-empty", "profile": profile_id,
|
|
141
|
+
"policy": {"id": _estate._show(_at(meta, "id")), "revision": _estate._js_to_number(_at(meta, "revision"))}}
|
|
102
142
|
|
|
103
143
|
|
|
104
144
|
def _finding(code: str, path: str, message: str) -> Dict[str, str]:
|
|
@@ -106,41 +146,41 @@ def _finding(code: str, path: str, message: str) -> Dict[str, str]:
|
|
|
106
146
|
|
|
107
147
|
|
|
108
148
|
def _model_catalogue(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
109
|
-
findings, seen = [],
|
|
110
|
-
for
|
|
111
|
-
if row
|
|
112
|
-
findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" %
|
|
113
|
-
seen.
|
|
149
|
+
findings, seen = [], set()
|
|
150
|
+
for row, index in _indexed(value.get("models")):
|
|
151
|
+
if _set_key(_id_of(row)) in seen:
|
|
152
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" % _show_id(row)))
|
|
153
|
+
seen.add(_set_key(_id_of(row)))
|
|
114
154
|
routing = row.get("provider") if isinstance(row.get("provider"), dict) else {}
|
|
115
155
|
ignored = set(_strings(routing.get("ignore")))
|
|
116
156
|
for slug in _strings(routing.get("order")):
|
|
117
157
|
if slug in ignored or slug.split("/")[0] in ignored:
|
|
118
|
-
findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (
|
|
158
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (_show_id(row), slug)))
|
|
119
159
|
return findings
|
|
120
160
|
|
|
121
161
|
|
|
122
162
|
def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
123
|
-
findings, seen = [],
|
|
124
|
-
for
|
|
125
|
-
if profile
|
|
126
|
-
findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" %
|
|
127
|
-
seen.
|
|
163
|
+
findings, seen = [], set()
|
|
164
|
+
for profile, index in _indexed(value.get("profiles")):
|
|
165
|
+
if _set_key(_id_of(profile)) in seen:
|
|
166
|
+
findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" % _show_id(profile)))
|
|
167
|
+
seen.add(_set_key(_id_of(profile)))
|
|
128
168
|
return findings
|
|
129
169
|
|
|
130
170
|
|
|
131
171
|
def _runner_profile(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
132
172
|
findings = []
|
|
133
173
|
if value.get("route") == "subscription" and isinstance(value.get("subscription"), dict):
|
|
134
|
-
login =
|
|
135
|
-
runner = SUBSCRIPTION_RUNNER.get(login)
|
|
136
|
-
if runner
|
|
174
|
+
login = _estate._show(_at(value["subscription"], "login"))
|
|
175
|
+
runner = SUBSCRIPTION_RUNNER.get(login, _MISSING_KEY)
|
|
176
|
+
if not _estate._same(runner, _at(value, "runnerKind")):
|
|
137
177
|
findings.append(_finding("FAC-SEM-059", "/runnerKind", "a %s subscription runs only in %s, not in %s; another runner takes the key route"
|
|
138
|
-
% (login, runner if runner is not
|
|
178
|
+
% (login, runner if runner is not _MISSING_KEY else "its vendor's runner", _estate._show(_at(value, "runnerKind")))))
|
|
139
179
|
if value.get("route") == "key" and isinstance(value.get("key"), dict):
|
|
140
180
|
key = value["key"]
|
|
141
|
-
if
|
|
181
|
+
if not _estate._same(_at(key, "modelAllowList"), _at(value, "modelProfile")):
|
|
142
182
|
findings.append(_finding("FAC-SEM-059", "/key/modelAllowList", "the key admits the models of %s, but the profile runs %s"
|
|
143
|
-
% (
|
|
183
|
+
% (_estate._show(_at(key, "modelAllowList")), _estate._show(_at(value, "modelProfile")))))
|
|
144
184
|
if isinstance(key.get("ref"), str) and _secrets.secret_value(key["ref"]):
|
|
145
185
|
findings.append(_finding("FAC-SEM-059", "/key/ref", "the key is named by its secret reference, never by its value"))
|
|
146
186
|
return findings
|
|
@@ -154,26 +194,85 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
154
194
|
findings = []
|
|
155
195
|
catalogue = value.get("catalogue")
|
|
156
196
|
catalogued = [row.get("id") for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)]
|
|
157
|
-
ids
|
|
158
|
-
for
|
|
159
|
-
if profile
|
|
160
|
-
findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" %
|
|
161
|
-
ids.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
197
|
+
ids = set()
|
|
198
|
+
for profile, i in _indexed(value.get("profiles")):
|
|
199
|
+
if _set_key(_id_of(profile)) in ids:
|
|
200
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" % _show_id(profile)))
|
|
201
|
+
ids.add(_set_key(_id_of(profile)))
|
|
202
|
+
# Every model by its index as written; only a string can name a catalogued model.
|
|
203
|
+
for j, model in enumerate(profile.get("models") if isinstance(profile.get("models"), list) else []):
|
|
204
|
+
if isinstance(model, str) and model not in catalogued:
|
|
205
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_show_id(profile), model)))
|
|
206
|
+
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
207
|
+
expected = _resolve(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
208
|
+
if not _json_equal(_at(value, "resolved"), expected):
|
|
209
|
+
findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _estate._json_stringify(_without_nan(expected))))
|
|
210
|
+
return findings
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def closed_user_content_route(profile: Any, catalogue: Any) -> Optional[List[str]]:
|
|
214
|
+
"""FAC-SEM-112 (DEC-0083) for one profile: the models that run when the lane reads user content and none of them is
|
|
215
|
+
an open-vendor model, or None when the profile does not read user content or its route has one. Under
|
|
216
|
+
``between-turns`` every model may run; under ``none``, or any other value (failing closed), only the first. A model
|
|
217
|
+
the catalogue does not hold, or whose row is not ``open-vendor``, counts as closed. Never raises."""
|
|
218
|
+
if not isinstance(profile, dict) or USER_CONTENT not in _strings(profile.get("reads")):
|
|
219
|
+
return None
|
|
220
|
+
models = profile.get("models") if isinstance(profile.get("models"), list) else []
|
|
221
|
+
route = models if profile.get("fallback") == "between-turns" else models[:1]
|
|
222
|
+
open_ids = {row["id"] for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)
|
|
223
|
+
if isinstance(row.get("id"), str) and OPEN_VENDOR in _strings(row.get("classes"))}
|
|
224
|
+
if any(isinstance(model, str) and model in open_ids for model in route):
|
|
225
|
+
return None
|
|
226
|
+
return [_estate._show(model) for model in route]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _closed_route(fallback: Any, closed: List[str]) -> str:
|
|
230
|
+
"""How a closed route is named in a finding: under ``between-turns`` all of it, otherwise its first model."""
|
|
231
|
+
return "none of its models is" if fallback == "between-turns" else "its first model, %s, is not" % (closed[0] if closed else "none")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _open_vendor_routes(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
235
|
+
"""FAC-SEM-112: a profile that reads user content has an open-vendor route, so the lane never runs on closed vendors
|
|
236
|
+
alone, which refuse the content it exists to handle. The resolution's outcome is held to the same test: when the
|
|
237
|
+
resolved profile reads user content and its own route is open, the models the policy leaves must still give one,
|
|
238
|
+
or the policy is named as what leaves the lane closed (one cause, one finding)."""
|
|
239
|
+
findings = []
|
|
240
|
+
profiles = _list(value.get("profiles"))
|
|
241
|
+
for profile, i in _indexed(value.get("profiles")):
|
|
242
|
+
closed = closed_user_content_route(profile, value.get("catalogue"))
|
|
243
|
+
if closed is None:
|
|
244
|
+
continue
|
|
245
|
+
findings.append(_finding("FAC-SEM-112", "/profiles/%d/models" % i, "profile %s reads user content, but %s open-vendor in "
|
|
246
|
+
"the catalogue: the lane would run on closed vendors alone" % (_show_id(profile), _closed_route(profile.get("fallback"), closed))))
|
|
165
247
|
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
166
|
-
|
|
167
|
-
if "
|
|
168
|
-
findings
|
|
248
|
+
outcome = resolve_model_profile(catalogue=value.get("catalogue"), profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
249
|
+
if "models" not in outcome:
|
|
250
|
+
return findings
|
|
251
|
+
profile = next((item for item in profiles if item.get("id") == outcome["profile"]), None) # a string: == is ===
|
|
252
|
+
if profile is None or closed_user_content_route(profile, value.get("catalogue")) is not None:
|
|
253
|
+
return findings
|
|
254
|
+
closed = closed_user_content_route(dict(profile, models=outcome["models"]), value.get("catalogue"))
|
|
255
|
+
if closed is None:
|
|
256
|
+
return findings
|
|
257
|
+
policy = value.get("policy")
|
|
258
|
+
meta = policy.get("meta") if isinstance(policy, dict) and isinstance(policy.get("meta"), dict) else {}
|
|
259
|
+
findings.append(_finding("FAC-SEM-112", "/resolved/models", "the policy %s leaves profile %s's lane on closed vendors alone: of the "
|
|
260
|
+
"models it admits, %s open-vendor in the catalogue" % (_estate._show(meta["id"]) if "id" in meta else "undefined",
|
|
261
|
+
_show_id(profile), _closed_route(profile.get("fallback"), closed))))
|
|
169
262
|
return findings
|
|
170
263
|
|
|
171
264
|
|
|
265
|
+
|
|
266
|
+
def _show_id(profile: Dict[str, Any]) -> str:
|
|
267
|
+
"""show(profile.id), where an absent id is JavaScript's undefined."""
|
|
268
|
+
return _estate._show(profile["id"]) if "id" in profile else "undefined"
|
|
269
|
+
|
|
270
|
+
|
|
172
271
|
def profile_problems(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
|
|
173
272
|
"""The contract's profileRules: findings for a `model-catalogue`, `model-profiles`, `runner-profile` or
|
|
174
273
|
`profile-resolution` document; None for another kind."""
|
|
175
274
|
return {"model-catalogue": _model_catalogue, "model-profiles": _model_profiles, "runner-profile": _runner_profile,
|
|
176
|
-
"profile-resolution": _profile_resolution}.get(kind, lambda v: None)(value)
|
|
275
|
+
"profile-resolution": lambda v: _profile_resolution(v) + _open_vendor_routes(v)}.get(kind, lambda v: None)(value)
|
|
177
276
|
|
|
178
277
|
|
|
179
278
|
# --- the doctor's checks ----------------------------------------------------------------------------------------
|
|
@@ -248,15 +347,20 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
248
347
|
|
|
249
348
|
def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
|
|
250
349
|
"""Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow."""
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
350
|
+
# The last row of an id wins, as a JavaScript Map built from the rows; only a string id can match a model's name.
|
|
351
|
+
rows = {row["id"]: row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None) if isinstance(row.get("id"), str)}
|
|
352
|
+
if not isinstance(model, str) or model not in rows:
|
|
353
|
+
return ["model %s is not in the model catalogue" % _estate._show(model)]
|
|
254
354
|
if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
|
|
255
355
|
allowed = set(_strings(policy["modelClasses"]))
|
|
256
|
-
|
|
356
|
+
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
357
|
+
policy_id = _estate._show(_at(meta, "id"))
|
|
358
|
+
admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
|
|
359
|
+
if not admission:
|
|
360
|
+
return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, policy_id)]
|
|
361
|
+
outside = [c for c in admission if c not in allowed]
|
|
257
362
|
if outside:
|
|
258
|
-
|
|
259
|
-
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), _js_string(meta.get("id")))]
|
|
363
|
+
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), policy_id)]
|
|
260
364
|
return []
|
|
261
365
|
|
|
262
366
|
|
|
@@ -300,13 +404,9 @@ _MISSING = object()
|
|
|
300
404
|
|
|
301
405
|
|
|
302
406
|
def _field(value: Dict[str, Any], key: str) -> str:
|
|
303
|
-
"""
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return "undefined"
|
|
307
|
-
if found is None:
|
|
308
|
-
return "null"
|
|
309
|
-
return _js_string(found)
|
|
407
|
+
"""The contract's show(value[key]): `undefined` for a missing key, `null` for null, and an object whose own
|
|
408
|
+
`toString` is data as its JSON, where String() would throw."""
|
|
409
|
+
return _estate._show(_at(value, key))
|
|
310
410
|
|
|
311
411
|
|
|
312
412
|
def _object_at(value: Any, key: str) -> Dict[str, Any]:
|
|
@@ -395,11 +495,14 @@ def _catalogue_agreement(catalogue: List[Dict[str, Any]], route: Dict[str, Any])
|
|
|
395
495
|
return findings
|
|
396
496
|
|
|
397
497
|
|
|
398
|
-
def _index_of(ref: Dict[str, Any]) ->
|
|
498
|
+
def _index_of(ref: Dict[str, Any]) -> float:
|
|
499
|
+
"""The contract's indexOf: a JSON number that is an integer, as JavaScript holds it (a double; beyond the double
|
|
500
|
+
range it is Infinity, which is no integer), else -1."""
|
|
399
501
|
index = ref.get("index")
|
|
400
|
-
if isinstance(index, bool) or not isinstance(index, (int, float))
|
|
502
|
+
if isinstance(index, bool) or not isinstance(index, (int, float)):
|
|
401
503
|
return -1
|
|
402
|
-
|
|
504
|
+
number = _estate._double(index)
|
|
505
|
+
return number if math.isfinite(number) and number.is_integer() else -1
|
|
403
506
|
|
|
404
507
|
|
|
405
508
|
def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
@@ -420,8 +523,8 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
420
523
|
|
|
421
524
|
def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
|
|
422
525
|
index = _index_of(ref)
|
|
423
|
-
candidate = candidates[index] if 0 <= index < len(candidates) else None
|
|
424
|
-
if (candidate is None or
|
|
526
|
+
candidate = candidates[int(index)] if 0 <= index < len(candidates) else None
|
|
527
|
+
if (candidate is None or not _estate._same(_at(candidate, "runnerKind"), _at(ref, "runnerKind"))
|
|
425
528
|
or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
|
|
426
529
|
wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
|
|
427
530
|
return None
|
|
@@ -435,12 +538,12 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
435
538
|
if candidate is not None and probe.get("result") == "spawn-failed" and _object_at(candidate, "session").get("spawn") != "allowed":
|
|
436
539
|
wrong("/probes/%d/result" % position, "%s has spawn: never, so it cannot have failed to spawn" % _field(probe, "runnerKind"))
|
|
437
540
|
|
|
438
|
-
def expect_probes(count:
|
|
541
|
+
def expect_probes(count: float) -> None:
|
|
439
542
|
indexes = [_index_of(p) for p in probes]
|
|
440
|
-
expected =
|
|
441
|
-
if indexes != expected:
|
|
543
|
+
expected = range(int(max(count, 0))) # Array.from({length: count}): ToLength, built only where it is read
|
|
544
|
+
if len(indexes) != len(expected) or any(index != expected[position] for position, index in enumerate(indexes)):
|
|
442
545
|
wrong("/probes", "the walk must record candidates %s in order; it records %s"
|
|
443
|
-
% (", ".join(map(
|
|
546
|
+
% (", ".join(map(_estate._js_string, expected)) or "none", ", ".join(map(_estate._js_string, indexes)) or "none"))
|
|
444
547
|
|
|
445
548
|
if event.get("kind") == "runner-exhausted":
|
|
446
549
|
expect_probes(len(candidates))
|
|
@@ -456,7 +559,7 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
456
559
|
wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
|
|
457
560
|
if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
|
|
458
561
|
wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
|
|
459
|
-
if "drive" in chosen and "drive" in selected and selected["drive"]
|
|
562
|
+
if "drive" in chosen and "drive" in selected and not _estate._same(selected["drive"], chosen["drive"]):
|
|
460
563
|
wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
|
|
461
564
|
if event.get("sticky") is True:
|
|
462
565
|
# A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.34.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
123
123
|
The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
|
|
124
124
|
does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
|
|
125
125
|
|
|
126
|
-
Pin exactly contract `0.1.0` at commit `
|
|
126
|
+
Pin exactly contract `0.1.0` at commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5` and
|
|
127
127
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
128
128
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
129
129
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -152,7 +152,11 @@ absent, apply the `fabric-service/0.1` rules from the pinned contract's
|
|
|
152
152
|
`docs/specification/service.md` by hand and mark its probe `NOT_RUN`.
|
|
153
153
|
|
|
154
154
|
Build the capability behind the chosen surface. Keep model choice and internal reasoning
|
|
155
|
-
outside the contract; expose typed outcomes, evidence, and protocol-visible state.
|
|
155
|
+
outside the contract; expose typed outcomes, evidence, and protocol-visible state. One fact
|
|
156
|
+
about each model lane is the contract's (DEC-0083): what it reads. Ask in the grill which
|
|
157
|
+
lanes read what members, clients or the public send, declare it as the profile's `reads`,
|
|
158
|
+
and give such a lane an open-vendor route (FAC-SEM-112; the `building-fabric-services`
|
|
159
|
+
profiles reference). Every
|
|
156
160
|
provider output is untrusted until schemas and semantic assertions pass. The step-2
|
|
157
161
|
observables are the definition of done; an observable attached after the code exists
|
|
158
162
|
lets the output decide what counts as success.
|
|
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
60
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
61
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
62
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
63
|
-
VERSION = "0.
|
|
63
|
+
VERSION = "0.34.1"
|
|
64
64
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
|
|
65
65
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
66
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|