@passioncode-ai/fabric-agent-adapter 0.32.0 → 0.33.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 +70 -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 +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +35 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +58 -16
- 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-estate.mjs +129 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +192 -50
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -3
- 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/references/project-board.md +4 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.33.0 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
The adapter is pinned at contract `82e1636`. The estate kits now read contexts (DEC-0082; FAA-41, the RM-28 pilot): one
|
|
6
|
+
agent placed on a machine for the person and for each organization it serves. Before this, at the old pin, a record
|
|
7
|
+
placing one agent in two contexts was refused as a duplicate placement (FAC-SEM-074), and an organization's instance
|
|
8
|
+
was checked against no share grant.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Repin** from `1636fa9` to `82e1636`. Every vendored vector is byte-identical except `estate-vectors.json`.
|
|
13
|
+
`1636fa9` stays a supported revision.
|
|
14
|
+
- **Placement and slot keys** in `fabric_estate.py` / `fabric-estate.mjs` carry the context: a placement is keyed by
|
|
15
|
+
agent and context (FAC-SEM-074), a secret slot by agent, slot and context (FAC-SEM-075). A secret fills a slot of an
|
|
16
|
+
agent placed in its own context. An absent `context` is `personal`; `null` is not absent.
|
|
17
|
+
- **`placement_trust(record, machine, agentRef, context="personal")`** / `placementTrust(…, context)` answer for the
|
|
18
|
+
placement in that context. A caller that passes none gets the personal placement, as before.
|
|
19
|
+
- **`estate-placement`** takes `{record, machine, package, context?}` and checks the placement in that context.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **FAC-SEM-110**: a share grant (`principalKind: external`) is never in the `personal` context. Checked on each of an
|
|
24
|
+
estate record's `shareGrants[]`, and on a grant of its own with `grant_context_findings` / `grantContextFindings`.
|
|
25
|
+
- **FAC-SEM-111**: an organization's placement names, in `grant`, a share grant of the record whose `context` is the
|
|
26
|
+
placement's, as written, and that the record does not revoke; a personal placement names none; a context that is not
|
|
27
|
+
`org:<orgId>` fails closed. Checked on a record and on an installation.
|
|
28
|
+
- **An organization's slot is filled from its own machine**: an envelope names no context (FAC-SEM-075).
|
|
29
|
+
- **`parse_context` / `parseContext`**: `personal`, `org:<orgId>` (`[A-Za-z0-9][A-Za-z0-9_-]{0,127}`), or nothing,
|
|
30
|
+
compared as written.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Every estate rule is total on hostile JSON**, as the contract's became in DEC-0082's fix rounds: an
|
|
35
|
+
`instancePath` names an item by its index as written (a non-object before it no longer shifts it), and a value whose
|
|
36
|
+
own `toString` member is data is shown as its JSON instead of throwing.
|
|
37
|
+
- **A flaky board test** (FAA-43): the hold test of `com.effect_begin` lifted the hold on a 50 ms timer, so a slow runner
|
|
38
|
+
saw one begin instead of two. The fake board now lifts it right after the first held begin (`lift_holds_after`), in
|
|
39
|
+
the Python and the Node twin.
|
|
40
|
+
|
|
41
|
+
### Evidence
|
|
42
|
+
|
|
43
|
+
- Both kits answer 115 contract rule cases (19 hostile, built by `make_estate_vectors.mjs` from the contexts fixture),
|
|
44
|
+
28 grant-context cases and 19 `placementTrust` cases.
|
|
45
|
+
- 28 planted defects, 14 per kit, all caught.
|
|
46
|
+
- A new eval scenario, `estate-contexts`.
|
|
47
|
+
## 0.32.1 - 2026-10-14
|
|
48
|
+
|
|
49
|
+
The `building-fabric-services` lifecycle reference is now the rule its readers follow (release iteration 1, N-12;
|
|
50
|
+
operator decision O6). It used to link the organization's private lifecycle contract and say "the contract wins and
|
|
51
|
+
this page is the defect", which left a public reader with a rule they could not open.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **`references/lifecycle.md` is normative.** A new *The rules* section states the lifecycle itself: the supervisor
|
|
56
|
+
owns the process (never kill a service; start, stop and restart only through launchd, systemd, the Task Scheduler
|
|
57
|
+
or the node; state is read from the supervisor; restart no sooner than 10 s; off stays off), the descriptor, and
|
|
58
|
+
readiness through the well-known document. A table states each lifecycle rule the kits serve (LC-01, -02, -03, -04,
|
|
59
|
+
-05, -09, -11, -12, -14, -15, -21, -22) in one sentence, with the section and the kit function that serve it. It
|
|
60
|
+
links only public sources: the Fabric Agent Contract's service specification, *Lifecycle*. The `LC-` ids remain
|
|
61
|
+
for provenance.
|
|
62
|
+
- **`working-with-fabric` `references/project-board.md`** no longer says the private page "wins": it is the rule a
|
|
63
|
+
coding agent with this plugin follows, and cites the knowledge base for provenance.
|
|
64
|
+
- The two kits' lifecycle comments (`fabric_service.py`, `fabric-service.mjs`) point at the reference and the
|
|
65
|
+
public specification instead of the private page.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`test/validate.py` refuses a skill page that defers to a private source**: a paragraph that names
|
|
70
|
+
`fabric-workspace` and says it "wins", "takes precedence", "prevails" or "is authoritative", or that this page "is
|
|
71
|
+
the defect". The validator's self-test plants one; `test/test_validate.py` covers the cases.
|
|
72
|
+
|
|
3
73
|
## 0.32.0 - 2026-10-14
|
|
4
74
|
|
|
5
75
|
The adapter is pinned at contract `1636fa9`. The board kits now read the operator's hold (DEC-0079) and the owner's
|
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: `82e163665e156a08aa3b92e4711015582c06162c`
|
|
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.33.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.33.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.33.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "82e163665e156a08aa3b92e4711015582c06162c"
|
|
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 `82e163665e156a08aa3b92e4711015582c06162c`.
|
|
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 = "82e163665e156a08aa3b92e4711015582c06162c"
|
|
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, "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
27
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "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"
|
|
@@ -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.33.0"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
|
-
extension-commit: "
|
|
24
|
+
extension-commit: "82e163665e156a08aa3b92e4711015582c06162c"
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
# Building Fabric services
|
|
@@ -86,7 +86,8 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
|
86
86
|
service checks each call's delegation token (DEC-0039): read [the delegation reference](references/delegation.md);
|
|
87
87
|
the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
|
|
88
88
|
[`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs). A service on the operator's machines that
|
|
89
|
-
applies the estate record (DEC-0055) verifies each revision first and trusts only the keys it certifies
|
|
89
|
+
applies the estate record (DEC-0055) verifies each revision first and trusts only the keys it certifies, and runs an
|
|
90
|
+
agent once per context, the person's or an organization's (DEC-0082): read
|
|
90
91
|
[the estate record reference](references/estate-record.md). When Fabric chooses the service's runner and models
|
|
91
92
|
(DEC-0046), or a project routes its work through an ordered list of runners (DEC-0026: the doctor's closed probe
|
|
92
93
|
results, the route checks FAC-SEM-028…034), read [the profiles reference](references/profiles.md).
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# The estate record — what the operator's machines are, signed by their passkey (DEC-0055)
|
|
2
2
|
|
|
3
3
|
Fabric Agent Contract DEC-0055, `docs/specification/estate-record.md`, schema `schemas/estate-record.schema.json`,
|
|
4
|
-
rules `FAC-SEM-072…076
|
|
4
|
+
rules `FAC-SEM-072…076`, with contexts (DEC-0082, `docs/specification/contexts.md`, `FAC-SEM-110`, `FAC-SEM-111`).
|
|
5
|
+
One revision of one person's estate lists:
|
|
5
6
|
- the passkeys that sign it;
|
|
6
7
|
- the machines, with their device keys and a certificate of each machine's hub key;
|
|
7
|
-
- the packages placed on each machine;
|
|
8
|
+
- the packages placed on each machine, each in a context: the person's own, or an organization's;
|
|
8
9
|
- where each secret slot is filled from (by reference only);
|
|
9
10
|
- share grants and revocations.
|
|
10
11
|
|
|
@@ -16,6 +17,7 @@ derived from its own digest. Python: [`scripts/fabric_estate.py`](../scripts/fab
|
|
|
16
17
|
|
|
17
18
|
- Applying a revision
|
|
18
19
|
- Trusting a delegation key
|
|
20
|
+
- Contexts: one agent for you and for an organization
|
|
19
21
|
- Checking a record you produce
|
|
20
22
|
- What the kit does not do
|
|
21
23
|
|
|
@@ -51,7 +53,8 @@ It checks, in the order of the ER table:
|
|
|
51
53
|
- The signature: ES256 or EdDSA, under a key validated first (a valid P-256 point; an Ed25519 key of full order).
|
|
52
54
|
7. **The window.** At most 30 days. A revision may be issued up to 60 s ahead of the verifier's clock (DEC-0066,
|
|
53
55
|
`CLOCK_SKEW_SECONDS`); earlier than that it is `not-yet-valid`.
|
|
54
|
-
8. **The record's own rules.** A violation is `rule-violation` with its `codes`, `FAC-SEM-072…076
|
|
56
|
+
8. **The record's own rules.** A violation is `rule-violation` with its `codes`, `FAC-SEM-072…076`, `FAC-SEM-110`,
|
|
57
|
+
`FAC-SEM-111` and `FAC-SEM-092`
|
|
55
58
|
(each passkey the record lists is a signing credential, its origins within its RP ID).
|
|
56
59
|
|
|
57
60
|
Dates are strict RFC 3339: a `T`, an offset, and a day that exists. `:60` and a space are refused. Passkey JWK `x`
|
|
@@ -90,14 +93,40 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=..., effects=..., esta
|
|
|
90
93
|
`{certified: true, machine, revision}` or `{certified: false, fault, detail}`. Both kits are held to the contract's
|
|
91
94
|
`fixtures/vectors/hub-key.json` (`test/fixtures/contract/hub-key-vectors.json`).
|
|
92
95
|
|
|
96
|
+
## Contexts: one agent for you and for an organization
|
|
97
|
+
|
|
98
|
+
A placement and a secret placement carry a `context` (DEC-0082): `personal`, or `org:<orgId>` with an organization id
|
|
99
|
+
of `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`. Absent is `personal`; `null` is not absent. A context is compared as written:
|
|
100
|
+
`Personal`, `ORG:x` or a trailing newline names no context. `parse_context(value)` / `parseContext(value)` answer
|
|
101
|
+
`{kind: "personal"}`, `{kind: "org", org}` or nothing.
|
|
102
|
+
|
|
103
|
+
- **One agent, once per context.** A machine may place the same `agentRef` for the person and for each organization;
|
|
104
|
+
placements are keyed by agent and context (FAC-SEM-074), and a secret slot by agent, slot and context (FAC-SEM-075).
|
|
105
|
+
A secret fills a slot of an agent placed on that machine in the secret's own context.
|
|
106
|
+
- **An organization's instance runs under a share grant** (FAC-SEM-111). Its placement names, in `grant`, a share grant
|
|
107
|
+
of the record whose `context` is the placement's, as written, and that the record does not revoke. A personal
|
|
108
|
+
placement names no grant. A context that is not `org:<orgId>` matches no grant, so it fails closed.
|
|
109
|
+
- **An organization's slot is filled from its own machine.** An envelope names no context, so a secret placed in an
|
|
110
|
+
organization's context has `from` equal to the machine it sits on (FAC-SEM-075).
|
|
111
|
+
- **A share grant is never personal** (FAC-SEM-110). A grant to a principal outside the estate (`principalKind:
|
|
112
|
+
external`) is used in that principal's organization; `context: "personal"` on it is a finding, and an absent one
|
|
113
|
+
means not stated. `grant_context_findings(grant, at)` / `grantContextFindings(grant, at)` check one grant on its own.
|
|
114
|
+
|
|
115
|
+
Every rule is total on hostile JSON, as the contract's are: an `instancePath` names an item by its index as written (a
|
|
116
|
+
non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
|
|
117
|
+
answer the contract's contexts fixtures and variants of them (`test/fixtures/contract/estate-vectors.json`).
|
|
118
|
+
|
|
93
119
|
## Checking a record you produce
|
|
94
120
|
|
|
95
121
|
`estate_record_rules(kind, value)` / `estateRecordRules(kind, value)` are the contract's rules, finding for finding:
|
|
96
|
-
- `estate-record` (FAC-SEM-072…076);
|
|
122
|
+
- `estate-record` (FAC-SEM-072…076, 110, 111);
|
|
97
123
|
- `estate-record-revision` `{parent, next}` (072);
|
|
98
|
-
- `estate-placement` `{record, machine, package}` (074, 075)
|
|
124
|
+
- `estate-placement` `{record, machine, package, context?}` (074, 075, 111): the package placed on that machine in that
|
|
125
|
+
context, `personal` when `context` is absent.
|
|
99
126
|
|
|
100
|
-
`placement_trust(record, machine, agentRef)`
|
|
127
|
+
`placement_trust(record, machine, agentRef, context="personal")` / `placementTrust(record, machine, agentRef, context)`
|
|
128
|
+
names the publisher keys a package verifier trusts for the package placed in that context. A caller that passes no
|
|
129
|
+
context gets the personal placement, as before contexts.
|
|
101
130
|
|
|
102
131
|
## What the kit does not do
|
|
103
132
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -1,31 +1,73 @@
|
|
|
1
1
|
# Lifecycle — supervisor, install, release layout, state
|
|
2
2
|
|
|
3
|
-
This page
|
|
4
|
-
|
|
5
|
-
(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
11
|
-
| LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
|
|
12
|
-
| LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
|
|
13
|
-
| LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
14
|
-
| LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
15
|
-
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
|
|
16
|
-
| LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
|
|
3
|
+
**This page is the rule** for anyone who builds, installs or runs a `fabric-service/0.1` service with this
|
|
4
|
+
skill. Everything you must follow is stated here or in the public Fabric Agent Contract's
|
|
5
|
+
[service specification, *Lifecycle*](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/specification/service.md#lifecycle), which this page
|
|
6
|
+
implements and links; where the two disagree, the specification is right and this page is the defect (report it in
|
|
7
|
+
this repository). Inside
|
|
8
|
+
PassionCode.ai the same rules make up the organization's product lifecycle contract, kept in its knowledge base. The
|
|
9
|
+
`LC-` ids below are that contract's, given for provenance only: nothing behind them is needed to follow this page.
|
|
17
10
|
|
|
18
11
|
## Contents
|
|
19
12
|
|
|
20
|
-
- [
|
|
13
|
+
- [The rules](#the-rules)
|
|
14
|
+
- [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes)
|
|
21
15
|
- [Install, upgrade, uninstall](#install-upgrade-uninstall)
|
|
22
16
|
- [One copy under a supervisor](#one-copy-under-a-supervisor)
|
|
17
|
+
- [An app's own descriptor and the node](#an-apps-own-descriptor-and-the-node)
|
|
23
18
|
- [Directories](#directories)
|
|
24
19
|
- [Logs](#logs)
|
|
25
20
|
- [Shutdown](#shutdown)
|
|
26
21
|
- [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
|
|
27
22
|
- [Linux and Windows](#linux-and-windows)
|
|
28
23
|
|
|
24
|
+
## The rules
|
|
25
|
+
|
|
26
|
+
**The supervisor owns the process.** A local service runs under exactly one supervisor, named in its descriptor's
|
|
27
|
+
`lifecycle.manager`: `launchd` (macOS), `systemd` (Linux), `task-scheduler` (Windows) or `node` (Project
|
|
28
|
+
Observatory's node supervising its children). An app a person starts says `person` and has no supervisor; where a host
|
|
29
|
+
runs a lifecycle broker, the broker is the one that quits it ([below](#being-managed-by-a-host-lifecycle-broker)).
|
|
30
|
+
Starting, stopping and restarting go through the supervisor and nothing else:
|
|
31
|
+
|
|
32
|
+
- **Never kill a service.** A host, an agent, an installer or the service's own CLI never starts a service process
|
|
33
|
+
directly and never kills or signals one. It asks the supervisor: on macOS `bootout` + `disable` to stop and keep
|
|
34
|
+
off, `enable` + `bootstrap` to start, `kickstart -k` to restart; [Linux and Windows](#linux-and-windows) have
|
|
35
|
+
their own verbs; a node's child is started, stopped and restarted only through the node.
|
|
36
|
+
- **State comes from the supervisor**: `launchctl print`, `systemctl --user show`, the task's state and last run,
|
|
37
|
+
or the node's record. Compare the supervisor's pid with the well-known `process.pid` to see a second copy.
|
|
38
|
+
- **Restart is the supervisor's.** A service that exits is started again, no sooner than 10 s after its last start
|
|
39
|
+
(`KeepAlive` with `ThrottleInterval` 10, `Restart=always` with `RestartSec=10`, the node likewise; a scheduled task
|
|
40
|
+
restarts on failure every minute). A restart onto new code shows as a new `service.build`.
|
|
41
|
+
- **Off stays off.** The operator's off survives a login, an upgrade and a reinstall (LC-14).
|
|
42
|
+
|
|
43
|
+
**The descriptor is the installation.** The installer writes one descriptor per instance (`<id>.<instance>.json`,
|
|
44
|
+
atomic, mode 0600) and the uninstaller removes it; a running service never writes its own, except an app
|
|
45
|
+
([below](#an-apps-own-descriptor-and-the-node)). It names the supervisor and its file (`label` and `plist`, `unit`,
|
|
46
|
+
or `task`; none for `node` and `person`), the `origin` `http://127.0.0.1:<port>` (a port is a claim: an installer
|
|
47
|
+
refuses a port another instance declares), the service's directories, and commands as argument arrays that never
|
|
48
|
+
carry a credential. Only `doctor` and `update` are commands a host may run.
|
|
49
|
+
|
|
50
|
+
**Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
|
|
51
|
+
authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's.
|
|
52
|
+
`status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
|
|
53
|
+
full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
|
|
54
|
+
that document until `service.id` matches (at most 40 s) before it calls the service started.
|
|
55
|
+
|
|
56
|
+
| Id | Rule | Here | Kit (Python / Node) |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit. Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
59
|
+
| LC-02 | **Every child has an owner, a deadline and a process group.** No subprocess without a timeout; on timeout or the parent's exit the whole group goes; no orphan survives. Locks and shared resources are released in `finally` | your own code | — |
|
|
60
|
+
| LC-03 | **A job is bounded and exclusive.** One copy holds `service.lock`; a scheduled job has a watchdog shorter than its interval, shares its lock with every manual entry point, bounds every network call and records each run's outcome. Long work is journalled before it starts | [One copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
|
|
61
|
+
| LC-04 | **Credentials are silent after setup.** No background prompt; a credential read that is denied or absent is cached and backs off until a person acts | [Directories](#directories) (secret files 0600) | — |
|
|
62
|
+
| LC-05 | **Identity is stable across updates.** The supervisor runs a stable executable inside a release directory, never an interpreter by a versioned package-manager path; signed builds are pinned by team and identifier | [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes) | — |
|
|
63
|
+
| LC-09 | **The footprint is declared.** The service's `AGENTS.md` names every resident process, its supervisor label, unit or task and its port, who starts each and who stops it | the descriptor and the supervisor file | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
|
|
64
|
+
| LC-11 | **An update is a lifecycle event.** An upgrade writes a new immutable release and restarts through the supervisor; the previous release is kept for rollback, older ones are pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
65
|
+
| LC-12 | **Every file is bounded.** Logs rotate by size (5 × 5 MB), mode 0600, never in `/tmp`; temporary leftovers are swept at start | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
66
|
+
| LC-14 | **The operator's intent wins; uninstall is symmetric.** No install or upgrade re-enables what the operator turned off; uninstall removes what install added (supervisor file, override, descriptor), and data goes only on an explicit purge | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
|
|
67
|
+
| LC-15 | **Builds clean up after themselves.** At most the current release and the one before it remain; the prune runs inside the installer, not later | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
68
|
+
| LC-21 | **A secret never travels in a command line.** Keys reach the process through its environment, a 0600 file or a vault, never argv; a supervisor file carries no secret | [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes), [Linux and Windows](#linux-and-windows) | the supervisor-file writers refuse a secret-shaped value |
|
|
69
|
+
| LC-22 | **Work survives a restart.** Work started but not finished is journalled on disk, without secrets, before it starts; the restarted service takes it back or cleans it up | [Shutdown](#shutdown) | — (the service's own journal) |
|
|
70
|
+
|
|
29
71
|
## launchd plist (generate it with `launchd_plist` or `launchdPlist`; both kits write the same bytes)
|
|
30
72
|
|
|
31
73
|
| Key | Value | Why |
|
|
@@ -168,7 +210,7 @@ has to survive being driven from outside:
|
|
|
168
210
|
|
|
169
211
|
- **Quit reaches exit through both doors.** A broker asks the platform's own quit first — on
|
|
170
212
|
macOS `NSRunningApplication.terminate()`, the path Cmd+Q and the Dock take — and `SIGTERM` a few
|
|
171
|
-
seconds later. Handle both and exit inside the deadline (
|
|
213
|
+
seconds later. Handle both and exit inside the deadline (LC-01). A desktop app
|
|
172
214
|
that answers `SIGTERM` by closing its window and staying alive is stopped late or not at all.
|
|
173
215
|
- **A background launch stays in the background.** A broker opens the app non-activating
|
|
174
216
|
(`NSWorkspace.OpenConfiguration.activates = false`); do not call `app.focus()`, `show()` on a
|
|
@@ -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 `82e163665e156a08aa3b92e4711015582c06162c`
|
|
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-estate.mjs
CHANGED
|
@@ -26,6 +26,41 @@ export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT:
|
|
|
26
26
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
27
|
const isString = (value) => typeof value === 'string';
|
|
28
28
|
const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
|
|
29
|
+
/** The object items of an array with their index as written, so an instancePath names the item even after a non-object. */
|
|
30
|
+
const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
|
|
31
|
+
/** The contract's `show`: String(value), or its JSON where String() throws (an object whose own `toString` is data). */
|
|
32
|
+
const show = (value) => {
|
|
33
|
+
if (typeof value === 'string') return value;
|
|
34
|
+
try {
|
|
35
|
+
return String(value);
|
|
36
|
+
} catch {
|
|
37
|
+
return JSON.stringify(value) ?? typeof value;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** The contract's `toNumber`: Number(value), or NaN where Number() throws. */
|
|
41
|
+
const toNumber = (value) => {
|
|
42
|
+
try {
|
|
43
|
+
return Number(value);
|
|
44
|
+
} catch {
|
|
45
|
+
return Number.NaN;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** DEC-0082: the personal context, and an organization's context `org:<orgId>` (the contract's `context-route.ts`). */
|
|
49
|
+
export const PERSONAL_CONTEXT = 'personal';
|
|
50
|
+
export const ORG_CONTEXT_PREFIX = 'org:';
|
|
51
|
+
const ORG_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
52
|
+
/** The context `value` names, or undefined: `personal` exactly, or `org:` and an organization id; compared as written. */
|
|
53
|
+
export function parseContext(value) {
|
|
54
|
+
if (typeof value !== 'string') return undefined;
|
|
55
|
+
if (value === PERSONAL_CONTEXT) return { kind: 'personal' };
|
|
56
|
+
if (!value.startsWith(ORG_CONTEXT_PREFIX)) return undefined;
|
|
57
|
+
const org = value.slice(ORG_CONTEXT_PREFIX.length);
|
|
58
|
+
return ORG_ID.test(org) ? { kind: 'org', org } : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** A placement's or a secret placement's context: its `context`, `personal` when absent (DEC-0082). */
|
|
61
|
+
const contextOf = (entry) => (entry.context === undefined ? PERSONAL_CONTEXT : entry.context);
|
|
62
|
+
/** A collision-free key over JSON values. */
|
|
63
|
+
const keyOf = (...parts) => JSON.stringify(parts);
|
|
29
64
|
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest();
|
|
30
65
|
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
31
66
|
const bytes = (text) => Buffer.from(text, 'base64url');
|
|
@@ -255,10 +290,13 @@ export function verifyEstateRecord(record, trust) {
|
|
|
255
290
|
return { ok: true, digest, revision: record.revision, signedBy: credential.credentialId, signCount: assertion.signCount };
|
|
256
291
|
}
|
|
257
292
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
293
|
+
/**
|
|
294
|
+
* What a package verifier trusts for a package placed on a machine in a context (DEC-0082; `personal` when not given);
|
|
295
|
+
* undefined when the record places no such agent there in that context.
|
|
296
|
+
*/
|
|
297
|
+
export function placementTrust(record, machineId, agentRef, context = PERSONAL_CONTEXT) {
|
|
260
298
|
const machine = list(record.machines).find((entry) => entry.id === machineId);
|
|
261
|
-
const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef);
|
|
299
|
+
const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef && contextOf(entry) === context);
|
|
262
300
|
if (!placement) return undefined;
|
|
263
301
|
return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
|
|
264
302
|
}
|
|
@@ -335,7 +373,7 @@ function revisionChain(value) {
|
|
|
335
373
|
const parent = isObject(value.parent) ? value.parent : {};
|
|
336
374
|
const next = isObject(value.next) ? value.next : {};
|
|
337
375
|
if (next.estate !== parent.estate) flag('/next/estate', "a revision continues its parent's estate");
|
|
338
|
-
if (next.revision !==
|
|
376
|
+
if (next.revision !== toNumber(parent.revision) + 1) flag('/next/revision', `the revision after ${show(parent.revision)} is ${toNumber(parent.revision) + 1}: a revision never skips or repeats one`);
|
|
339
377
|
let parentDigest;
|
|
340
378
|
try {
|
|
341
379
|
parentDigest = estateRecordDigest(parent);
|
|
@@ -345,14 +383,14 @@ function revisionChain(value) {
|
|
|
345
383
|
if (next.parent !== parentDigest) flag('/next/parent', 'parent names the digest of the revision before it');
|
|
346
384
|
if (estateTime(next.issuedAt) < estateTime(parent.issuedAt)) flag('/next/issuedAt', 'a revision is never issued before its parent');
|
|
347
385
|
const kept = new Set(list(next.revocations).map(revocationKey));
|
|
348
|
-
|
|
349
|
-
if (!kept.has(revocationKey(entry))) flag(`/parent/revocations/${index}`, `the ${
|
|
386
|
+
indexed(parent.revocations).forEach(([entry, index]) => {
|
|
387
|
+
if (!kept.has(revocationKey(entry))) flag(`/parent/revocations/${index}`, `the ${show(entry.kind)} revocation of ${show(entry.ref)} is dropped: a revision keeps every revocation before it`);
|
|
350
388
|
});
|
|
351
389
|
return findings;
|
|
352
390
|
}
|
|
353
391
|
|
|
354
392
|
const validDeviceKey = (key) => {
|
|
355
|
-
const raw = Buffer.from(
|
|
393
|
+
const raw = Buffer.from(show(key.publicKey ?? ''), 'base64');
|
|
356
394
|
return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
|
|
357
395
|
};
|
|
358
396
|
|
|
@@ -360,69 +398,73 @@ function keys(value, flag) {
|
|
|
360
398
|
const ids = new Set();
|
|
361
399
|
const hubKids = new Set();
|
|
362
400
|
const hubKeys = new Set();
|
|
363
|
-
|
|
401
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
364
402
|
const at = `/machines/${m}`;
|
|
365
|
-
if (ids.has(machine.id)) flag(`${at}/id`, `machine ${
|
|
403
|
+
if (ids.has(machine.id)) flag(`${at}/id`, `machine ${show(machine.id)} is listed twice`);
|
|
366
404
|
ids.add(machine.id);
|
|
367
405
|
const deviceKeys = list(machine.deviceKeys);
|
|
368
406
|
if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
|
|
369
407
|
if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
|
|
370
408
|
const seen = new Set();
|
|
371
409
|
deviceKeys.forEach((key, k) => {
|
|
372
|
-
if (key.kid !== `${
|
|
410
|
+
if (key.kid !== `${show(machine.id)}#${show(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
|
|
373
411
|
if (!validDeviceKey(key)) flag(`${at}/deviceKeys/${k}/publicKey`, 'an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)');
|
|
374
412
|
if (seen.has(key.publicKey)) flag(`${at}/deviceKeys/${k}/publicKey`, 'one key is never used for the other role');
|
|
375
413
|
seen.add(key.publicKey);
|
|
376
414
|
});
|
|
377
415
|
if (isObject(machine.hubKey)) {
|
|
378
416
|
const hub = machine.hubKey;
|
|
379
|
-
if (hubKids.has(hub.kid)) flag(`${at}/hubKey/kid`, `hub key ${
|
|
417
|
+
if (hubKids.has(hub.kid)) flag(`${at}/hubKey/kid`, `hub key ${show(hub.kid)} is certified for two machines: one kid per machine`);
|
|
380
418
|
hubKids.add(hub.kid);
|
|
381
419
|
if (hubKeys.has(hub.x)) flag(`${at}/hubKey/x`, 'one hub public key is certified for two machines under two kids: one key per machine');
|
|
382
420
|
hubKeys.add(hub.x);
|
|
383
|
-
if (smallOrder(Buffer.from(
|
|
421
|
+
if (smallOrder(Buffer.from(show(hub.x ?? ''), 'base64url'))) flag(`${at}/hubKey/x`, 'a hub key of small order verifies forged tokens and is never certified (DEC-0045)');
|
|
384
422
|
}
|
|
385
423
|
});
|
|
386
424
|
const credentials = new Set();
|
|
387
|
-
|
|
425
|
+
indexed(value.authorities).forEach(([authority, index]) => {
|
|
388
426
|
if (credentials.has(authority.credentialId)) flag(`/authorities/${index}/credentialId`, 'a passkey is listed once');
|
|
389
427
|
credentials.add(authority.credentialId);
|
|
390
|
-
if ('problem' in credentialKey(authority.publicKey,
|
|
428
|
+
if ('problem' in credentialKey(authority.publicKey, show(authority.alg))) flag(`/authorities/${index}/publicKey`, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA");
|
|
391
429
|
});
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
function placements(value, flag) {
|
|
395
433
|
const kids = new Set();
|
|
396
|
-
|
|
397
|
-
if (kids.has(key.kid)) flag(`/publisherKeys/${index}/kid`, `publisher key ${
|
|
434
|
+
indexed(value.publisherKeys).forEach(([key, index]) => {
|
|
435
|
+
if (kids.has(key.kid)) flag(`/publisherKeys/${index}/kid`, `publisher key ${show(key.kid)} is pinned twice`);
|
|
398
436
|
kids.add(key.kid);
|
|
399
437
|
});
|
|
400
|
-
|
|
438
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
401
439
|
const agents = new Set();
|
|
402
|
-
|
|
440
|
+
indexed(machine.packages).forEach(([placement, p]) => {
|
|
403
441
|
const at = `/machines/${m}/packages/${p}`;
|
|
404
|
-
|
|
405
|
-
agents.
|
|
406
|
-
|
|
442
|
+
const key = keyOf(placement.agentRef, contextOf(placement));
|
|
443
|
+
if (agents.has(key)) flag(`${at}/agentRef`, `${show(placement.agentRef)} is placed twice on one machine in the context ${show(contextOf(placement))}`);
|
|
444
|
+
agents.add(key);
|
|
445
|
+
if (!kids.has(placement.publisherKid)) flag(`${at}/publisherKid`, `publisher key ${show(placement.publisherKid)} is not in publisherKeys, so the machine pins no key to verify the package under`);
|
|
407
446
|
});
|
|
408
447
|
});
|
|
409
448
|
}
|
|
410
449
|
|
|
411
450
|
function secretReferences(value, flag) {
|
|
412
451
|
const machineIds = new Set(list(value.machines).map((machine) => machine.id));
|
|
413
|
-
|
|
414
|
-
const placed = new Set(list(machine.packages).map((placement) => placement.agentRef));
|
|
452
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
453
|
+
const placed = new Set(list(machine.packages).map((placement) => keyOf(placement.agentRef, contextOf(placement))));
|
|
415
454
|
const slots = new Set();
|
|
416
|
-
|
|
455
|
+
indexed(machine.secrets).forEach(([secret, s]) => {
|
|
417
456
|
const at = `/machines/${m}/secrets/${s}`;
|
|
418
|
-
|
|
457
|
+
// The message never repeats the value, and no message of this rule repeats a secret placement's context.
|
|
458
|
+
for (const field of ['slot', 'from', 'agentRef', 'context']) {
|
|
419
459
|
const text = secret[field];
|
|
420
460
|
if (isString(text) && secretValue(text)) flag(`${at}/${field}`, 'a secret placement names a slot, never a value: this looks like a credential');
|
|
421
461
|
}
|
|
422
|
-
if (!placed.has(secret.agentRef)) flag(`${at}/agentRef`,
|
|
462
|
+
if (!placed.has(keyOf(secret.agentRef, contextOf(secret)))) flag(`${at}/agentRef`, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context");
|
|
423
463
|
if (!machineIds.has(secret.from)) flag(`${at}/from`, 'the slot is filled from a machine of this estate');
|
|
424
|
-
|
|
425
|
-
if (
|
|
464
|
+
// An envelope names no context (DEC-0082), so an organization's instance fills its slot on its own machine.
|
|
465
|
+
else if (contextOf(secret) !== PERSONAL_CONTEXT && secret.from !== machine.id) flag(`${at}/from`, "an organization's instance fills its slot from its own machine's vault, never by an envelope from another machine");
|
|
466
|
+
const key = keyOf(secret.agentRef, secret.slot, contextOf(secret));
|
|
467
|
+
if (slots.has(key)) flag(`${at}/slot`, `slot ${show(secret.slot)} is placed twice for one agent in one context on one machine`);
|
|
426
468
|
slots.add(key);
|
|
427
469
|
});
|
|
428
470
|
});
|
|
@@ -432,10 +474,10 @@ function grantsAndRevocations(value, flag) {
|
|
|
432
474
|
// A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076).
|
|
433
475
|
const grantKey = (id) => (typeof id === 'string' ? normalIdentifier(id) : id);
|
|
434
476
|
const grantIds = new Set();
|
|
435
|
-
|
|
477
|
+
indexed(value.shareGrants).forEach(([grant, index]) => {
|
|
436
478
|
if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
|
|
437
479
|
const id = isObject(grant.meta) ? grant.meta.id : undefined;
|
|
438
|
-
if (grantIds.has(grantKey(id))) flag(`/shareGrants/${index}/meta/id`, `grant ${
|
|
480
|
+
if (grantIds.has(grantKey(id))) flag(`/shareGrants/${index}/meta/id`, `grant ${show(id)} is listed twice`);
|
|
439
481
|
grantIds.add(grantKey(id));
|
|
440
482
|
});
|
|
441
483
|
const machines = list(value.machines);
|
|
@@ -450,12 +492,50 @@ function grantsAndRevocations(value, flag) {
|
|
|
450
492
|
grant: grantIds,
|
|
451
493
|
}));
|
|
452
494
|
const seen = new Set();
|
|
453
|
-
|
|
495
|
+
indexed(value.revocations).forEach(([revocation, index]) => {
|
|
454
496
|
const ref = revocation.kind === 'grant' ? grantKey(revocation.ref) : revocation.ref;
|
|
455
|
-
const key = `${
|
|
456
|
-
if (seen.has(key)) flag(`/revocations/${index}`, `the ${
|
|
497
|
+
const key = `${show(revocation.kind)} ${show(ref)}`;
|
|
498
|
+
if (seen.has(key)) flag(`/revocations/${index}`, `the ${show(revocation.kind)} ${show(revocation.ref)} is revoked twice`);
|
|
457
499
|
seen.add(key);
|
|
458
|
-
if (listed.get(
|
|
500
|
+
if (listed.get(show(revocation.kind))?.has(ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${show(revocation.kind)} it revokes: revocation outranks addition`);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* FAC-SEM-110 (DEC-0082): a grant to a principal outside the estate (`principalKind: external`) is used in that
|
|
506
|
+
* principal's organization, so its `context` is never `personal`; absent means not stated.
|
|
507
|
+
*/
|
|
508
|
+
export function grantContextFindings(grant, at) {
|
|
509
|
+
if (!isObject(grant) || grant.principalKind !== 'external' || grant.context !== PERSONAL_CONTEXT) return [];
|
|
510
|
+
return [{ code: 'FAC-SEM-110', instancePath: `${at}/context`, message: "a grant to a principal outside the estate is used in that principal's organization, never in the personal context: name the organization's org:<orgId>, or leave context out" }];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* FAC-SEM-111 (DEC-0082) for one placement: an instance in an organization's context names, in `grant`, a share grant
|
|
515
|
+
* of the same record (by the normal form of its id, DEC-0076) whose `context` is the placement's, as written, and that
|
|
516
|
+
* the record does not revoke; a personal instance names none; a malformed context fails closed.
|
|
517
|
+
*/
|
|
518
|
+
function placementGrantProblems(record, placement) {
|
|
519
|
+
const context = contextOf(placement);
|
|
520
|
+
if (context === PERSONAL_CONTEXT) {
|
|
521
|
+
return placement.grant === undefined ? [] : [['/grant', "a personal instance runs under no share grant: only a placement in an organization's context names one"]];
|
|
522
|
+
}
|
|
523
|
+
if (typeof placement.grant !== 'string') return [['/grant', `an instance placed in the context ${show(context)} names the share grant it runs under, by its id`]];
|
|
524
|
+
const id = normalIdentifier(placement.grant);
|
|
525
|
+
const grants = list(record.shareGrants).filter((grant) => isObject(grant.meta) && typeof grant.meta.id === 'string' && normalIdentifier(grant.meta.id) === id);
|
|
526
|
+
const problems = [];
|
|
527
|
+
if (grants.length === 0) problems.push(['/grant', `the record has no share grant ${placement.grant}`]);
|
|
528
|
+
else if (parseContext(context)?.kind !== 'org') problems.push(['/context', `${show(context)} is not an organization's context id (org:<orgId>), so no share grant matches it`]);
|
|
529
|
+
else if (!grants.some((grant) => grant.context === context)) problems.push(['/grant', `the share grant ${placement.grant} is not for the context ${show(context)}: its context must be the placement's, as written`]);
|
|
530
|
+
if (list(record.revocations).some((entry) => entry.kind === 'grant' && typeof entry.ref === 'string' && normalIdentifier(entry.ref) === id)) problems.push(['/grant', `the record revokes the share grant ${placement.grant}, so no instance runs under it`]);
|
|
531
|
+
return problems;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function placementGrants(value, flag) {
|
|
535
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
536
|
+
indexed(machine.packages).forEach(([placement, p]) => {
|
|
537
|
+
for (const [suffix, message] of placementGrantProblems(value, placement)) flag(`/machines/${m}/packages/${p}${suffix}`, message);
|
|
538
|
+
});
|
|
459
539
|
});
|
|
460
540
|
}
|
|
461
541
|
|
|
@@ -466,8 +546,10 @@ function recordFindings(value) {
|
|
|
466
546
|
placements(value, flagger(findings, 'FAC-SEM-074'));
|
|
467
547
|
secretReferences(value, flagger(findings, 'FAC-SEM-075'));
|
|
468
548
|
grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
|
|
549
|
+
indexed(value.shareGrants).forEach(([grant, index]) => findings.push(...grantContextFindings(grant, `/shareGrants/${index}`)));
|
|
550
|
+
placementGrants(value, flagger(findings, 'FAC-SEM-111'));
|
|
469
551
|
// FAC-SEM-092 (DEC-0063) on each passkey the record lists.
|
|
470
|
-
|
|
552
|
+
indexed(value.authorities).forEach(([authority, index]) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
|
|
471
553
|
return findings;
|
|
472
554
|
}
|
|
473
555
|
|
|
@@ -480,8 +562,7 @@ function installation(value) {
|
|
|
480
562
|
const findings = [];
|
|
481
563
|
const record = isObject(value.record) ? value.record : {};
|
|
482
564
|
const pkg = isObject(value.package) ? value.package : {};
|
|
483
|
-
const machineIndex =
|
|
484
|
-
const machine = list(record.machines)[machineIndex];
|
|
565
|
+
const [machine, machineIndex] = indexed(record.machines).find(([entry]) => entry.id === value.machine) ?? [undefined, -1];
|
|
485
566
|
let digest;
|
|
486
567
|
try {
|
|
487
568
|
digest = packageDigest(pkg);
|
|
@@ -490,18 +571,23 @@ function installation(value) {
|
|
|
490
571
|
}
|
|
491
572
|
const flag074 = flagger(findings, 'FAC-SEM-074');
|
|
492
573
|
if (!machine) {
|
|
493
|
-
flag074('/machine', `machine ${
|
|
574
|
+
flag074('/machine', `machine ${show(value.machine)} is not in the estate record`);
|
|
494
575
|
return findings;
|
|
495
576
|
}
|
|
496
577
|
if (digest === undefined || digest !== pkg.digest) flag074('/package/digest', 'the package is not the one its digest names: it changed after it was packed');
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
578
|
+
const context = contextOf(value);
|
|
579
|
+
const [placement, placementIndex] = indexed(machine.packages).find(([entry]) => entry.agentRef === pkg.agentRef && contextOf(entry) === context) ?? [undefined, -1];
|
|
580
|
+
if (!placement) flag074('/package/agentRef', `the record places no ${show(pkg.agentRef)} on machine ${show(value.machine)} in the context ${show(context)}`);
|
|
581
|
+
else if (placement.digest !== digest) flag074('/package', `the record places ${show(placement.digest)} of this agent on this machine in this context, not this package`);
|
|
500
582
|
const declared = new Set(list(pkg.secrets).map((slot) => slot.name));
|
|
501
583
|
const flag075 = flagger(findings, 'FAC-SEM-075');
|
|
502
|
-
|
|
503
|
-
if (secret.agentRef === pkg.agentRef && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${
|
|
584
|
+
indexed(machine.secrets).forEach(([secret, index]) => {
|
|
585
|
+
if (secret.agentRef === pkg.agentRef && contextOf(secret) === context && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${show(secret.slot)}: a secret goes only where a placed package declares it`);
|
|
504
586
|
});
|
|
587
|
+
if (placement) {
|
|
588
|
+
const flag111 = flagger(findings, 'FAC-SEM-111');
|
|
589
|
+
for (const [suffix, message] of placementGrantProblems(record, placement)) flag111(`/record/machines/${machineIndex}/packages/${placementIndex}${suffix}`, message);
|
|
590
|
+
}
|
|
505
591
|
return findings;
|
|
506
592
|
}
|
|
507
593
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -20,7 +20,8 @@ export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-cont
|
|
|
20
20
|
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.0';
|
|
21
21
|
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
22
22
|
export const EXIT_ALREADY_RUNNING = 75;
|
|
23
|
-
// Lifecycle
|
|
23
|
+
// Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
|
|
24
|
+
// contract's service specification, Lifecycle), the same values as fabric_service.py.
|
|
24
25
|
// A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
|
|
25
26
|
export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
|
|
26
27
|
export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -151,6 +151,84 @@ def _list(value: Any) -> List[Dict[str, Any]]:
|
|
|
151
151
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def _indexed(value: Any) -> List[Tuple[Dict[str, Any], int]]:
|
|
155
|
+
"""The object items of an array with their index in the array as written, so an instancePath names the item it
|
|
156
|
+
flags even when a non-object precedes it (the contract's ``indexed``)."""
|
|
157
|
+
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _Unshowable(Exception):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _strict_string(value: Any) -> str:
|
|
165
|
+
"""String(value), raising where JavaScript's String() throws: an object whose own ``toString`` is not a function
|
|
166
|
+
(every own member of parsed JSON is data), directly or inside an array it joins."""
|
|
167
|
+
if isinstance(value, dict):
|
|
168
|
+
if "toString" in value:
|
|
169
|
+
raise _Unshowable()
|
|
170
|
+
return "[object Object]"
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
return ",".join("" if item is None else _strict_string(item) for item in value)
|
|
173
|
+
return _js_string(value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _json_stringify(value: Any) -> str:
|
|
177
|
+
"""JSON.stringify for a parsed JSON value: members in their order, numbers as JavaScript writes them."""
|
|
178
|
+
if isinstance(value, dict):
|
|
179
|
+
return "{" + ",".join(_delegation._string(k) + ":" + _json_stringify(v) for k, v in value.items()) + "}"
|
|
180
|
+
if isinstance(value, list):
|
|
181
|
+
return "[" + ",".join(_json_stringify(v) for v in value) + "]"
|
|
182
|
+
if value is None:
|
|
183
|
+
return "null"
|
|
184
|
+
if isinstance(value, bool):
|
|
185
|
+
return "true" if value else "false"
|
|
186
|
+
if _number(value):
|
|
187
|
+
text = _js_string(value)
|
|
188
|
+
return "null" if text in ("NaN", "Infinity", "-Infinity") else text
|
|
189
|
+
return _delegation._string(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _show(value: Any) -> str:
|
|
193
|
+
"""The contract's ``show``: String(value), or the value's JSON where String() would throw."""
|
|
194
|
+
try:
|
|
195
|
+
return _strict_string(value)
|
|
196
|
+
except _Unshowable:
|
|
197
|
+
return _json_stringify(value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _key_of(*parts: Any) -> str:
|
|
201
|
+
"""The contract's ``keyOf``: a collision-free key over JSON values, JSON.stringify of the parts (an absent value
|
|
202
|
+
is null inside an array, as JavaScript writes it)."""
|
|
203
|
+
return _json_stringify([None if part is _MISSING else part for part in parts])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
#: DEC-0082: the personal context, and an organization's context ``org:<orgId>`` (the contract's ``context-route.ts``).
|
|
207
|
+
PERSONAL_CONTEXT = "personal"
|
|
208
|
+
ORG_CONTEXT_PREFIX = "org:"
|
|
209
|
+
_ORG_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_context(value: Any) -> Optional[Dict[str, str]]:
|
|
213
|
+
"""The context ``value`` names, or None when it names none: ``personal`` exactly, or ``org:`` and an organization id
|
|
214
|
+
(``[A-Za-z0-9][A-Za-z0-9_-]{0,127}``). Compared as written: ``Personal``, ``ORG:x`` or a trailing newline name no
|
|
215
|
+
context. Never raises."""
|
|
216
|
+
if not isinstance(value, str):
|
|
217
|
+
return None
|
|
218
|
+
if value == PERSONAL_CONTEXT:
|
|
219
|
+
return {"kind": "personal"}
|
|
220
|
+
if not value.startswith(ORG_CONTEXT_PREFIX):
|
|
221
|
+
return None
|
|
222
|
+
org = value[len(ORG_CONTEXT_PREFIX):]
|
|
223
|
+
return {"kind": "org", "org": org} if _ORG_ID.fullmatch(org) else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _context_of(entry: Any) -> Any:
|
|
227
|
+
"""A placement's or a secret placement's context: its ``context``, ``personal`` when absent (DEC-0082)."""
|
|
228
|
+
context = _get(entry, "context")
|
|
229
|
+
return PERSONAL_CONTEXT if context is _MISSING else context
|
|
230
|
+
|
|
231
|
+
|
|
154
232
|
def _b64url_bytes(text: str) -> bytes:
|
|
155
233
|
"""Buffer.from(text, "base64url") for text of the base64url alphabet: a dangling sixth bit group is ignored."""
|
|
156
234
|
return _delegation._b64decode(text)
|
|
@@ -514,7 +592,7 @@ def _signing_credential(credential: Any, path: str, flag) -> None:
|
|
|
514
592
|
|
|
515
593
|
|
|
516
594
|
def _revocation_key(entry: Dict[str, Any]) -> str:
|
|
517
|
-
return "%s %s" % (
|
|
595
|
+
return "%s %s" % (_show(_get(entry, "kind")), _show(_get(entry, "ref")))
|
|
518
596
|
|
|
519
597
|
|
|
520
598
|
def _lt(a: float, b: float) -> bool:
|
|
@@ -610,11 +688,13 @@ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, author
|
|
|
610
688
|
return {"ok": True, "digest": digest, "revision": record["revision"], "signedBy": credential["credentialId"], "signCount": assertion["signCount"]}
|
|
611
689
|
|
|
612
690
|
|
|
613
|
-
def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str) -> Optional[Dict[str, Any]]:
|
|
614
|
-
"""What a package verifier (DEC-0051) trusts for a package placed on a machine
|
|
615
|
-
|
|
691
|
+
def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str, context: str = PERSONAL_CONTEXT) -> Optional[Dict[str, Any]]:
|
|
692
|
+
"""What a package verifier (DEC-0051) trusts for a package placed on a machine in a context (DEC-0082; ``personal``
|
|
693
|
+
when not given, so a caller written before contexts is unchanged): the publisher keys the placement names, from the
|
|
694
|
+
record's `publisherKeys`. None when the record places no such agent on that machine in that context."""
|
|
616
695
|
machine = next((m for m in _list(_get(record, "machines")) if _same(_get(m, "id"), machine_id)), None)
|
|
617
|
-
placement = next((p for p in _list(_get(machine, "packages"))
|
|
696
|
+
placement = next((p for p in _list(_get(machine, "packages"))
|
|
697
|
+
if _same(_get(p, "agentRef"), agent_ref) and _same(_context_of(p), context)), None)
|
|
618
698
|
if placement is None:
|
|
619
699
|
return None
|
|
620
700
|
return {"publisher": [k for k in _list(_get(record, "publisherKeys")) if _same(_get(k, "kid"), _get(placement, "publisherKid"))]}
|
|
@@ -720,7 +800,7 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
720
800
|
flag("/next/estate", "a revision continues its parent's estate")
|
|
721
801
|
expected = _js_to_number(_get(parent, "revision")) + 1
|
|
722
802
|
if not _same(_get(nxt, "revision"), expected):
|
|
723
|
-
flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (
|
|
803
|
+
flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (_show(_get(parent, "revision")), _js_string(expected)))
|
|
724
804
|
try:
|
|
725
805
|
parent_digest: Any = estate_record_digest(parent)
|
|
726
806
|
except TypeError:
|
|
@@ -730,15 +810,15 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
730
810
|
if _lt(estate_time(_get(nxt, "issuedAt")), estate_time(_get(parent, "issuedAt"))):
|
|
731
811
|
flag("/next/issuedAt", "a revision is never issued before its parent")
|
|
732
812
|
kept = {_revocation_key(e) for e in _list(_get(nxt, "revocations"))}
|
|
733
|
-
for
|
|
813
|
+
for entry, index in _indexed(_get(parent, "revocations")):
|
|
734
814
|
if _revocation_key(entry) not in kept:
|
|
735
|
-
flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (
|
|
815
|
+
flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (_show(_get(entry, "kind")), _show(_get(entry, "ref"))))
|
|
736
816
|
return findings
|
|
737
817
|
|
|
738
818
|
|
|
739
819
|
def _valid_device_key(key: Dict[str, Any]) -> bool:
|
|
740
820
|
public = _get(key, "publicKey")
|
|
741
|
-
raw = _node_base64("" if public is _MISSING or public is None else
|
|
821
|
+
raw = _node_base64("" if public is _MISSING or public is None else _show(public))
|
|
742
822
|
if _get(key, "alg") == "ed25519":
|
|
743
823
|
return len(raw) == 32 and not _delegation.is_small_order_key(raw)
|
|
744
824
|
return is_valid_p256_key(raw)
|
|
@@ -748,10 +828,10 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
748
828
|
ids: List[Any] = []
|
|
749
829
|
hub_kids: List[Any] = []
|
|
750
830
|
hub_keys: List[Any] = []
|
|
751
|
-
for
|
|
831
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
752
832
|
at = "/machines/%d" % m
|
|
753
833
|
if _contains(ids, _get(machine, "id")):
|
|
754
|
-
flag("%s/id" % at, "machine %s is listed twice" %
|
|
834
|
+
flag("%s/id" % at, "machine %s is listed twice" % _show(_get(machine, "id")))
|
|
755
835
|
ids.append(_get(machine, "id"))
|
|
756
836
|
device_keys = _list(_get(machine, "deviceKeys"))
|
|
757
837
|
if len([k for k in device_keys if _get(k, "use") == "sig"]) != 1:
|
|
@@ -759,8 +839,8 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
759
839
|
if len([k for k in device_keys if _get(k, "use") == "kem"]) > 1:
|
|
760
840
|
flag("%s/deviceKeys" % at, "a machine has at most one key-agreement device key, <id>#kem")
|
|
761
841
|
seen: List[Any] = []
|
|
762
|
-
for
|
|
763
|
-
if not _same(_get(key, "kid"), "%s#%s" % (
|
|
842
|
+
for key, k in _indexed(_get(machine, "deviceKeys")):
|
|
843
|
+
if not _same(_get(key, "kid"), "%s#%s" % (_show(_get(machine, "id")), _show(_get(key, "use")))):
|
|
764
844
|
flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
|
|
765
845
|
if not _valid_device_key(key):
|
|
766
846
|
flag("%s/deviceKeys/%d/publicKey" % (at, k), "an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)")
|
|
@@ -770,7 +850,7 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
770
850
|
hub = _get(machine, "hubKey")
|
|
771
851
|
if isinstance(hub, dict):
|
|
772
852
|
if _contains(hub_kids, _get(hub, "kid")):
|
|
773
|
-
flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" %
|
|
853
|
+
flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" % _show(_get(hub, "kid")))
|
|
774
854
|
hub_kids.append(_get(hub, "kid"))
|
|
775
855
|
if _contains(hub_keys, _get(hub, "x")):
|
|
776
856
|
flag("%s/hubKey/x" % at, "one hub public key is certified for two machines under two kids: one key per machine")
|
|
@@ -780,49 +860,55 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
780
860
|
if _delegation.is_small_order_key(raw):
|
|
781
861
|
flag("%s/hubKey/x" % at, "a hub key of small order verifies forged tokens and is never certified (DEC-0045)")
|
|
782
862
|
credentials: List[Any] = []
|
|
783
|
-
for
|
|
863
|
+
for authority, index in _indexed(_get(value, "authorities")):
|
|
784
864
|
if _contains(credentials, _get(authority, "credentialId")):
|
|
785
865
|
flag("/authorities/%d/credentialId" % index, "a passkey is listed once")
|
|
786
866
|
credentials.append(_get(authority, "credentialId"))
|
|
787
|
-
if "problem" in credential_key(_get(authority, "publicKey"),
|
|
867
|
+
if "problem" in credential_key(_get(authority, "publicKey"), _show(_get(authority, "alg"))):
|
|
788
868
|
flag("/authorities/%d/publicKey" % index, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA")
|
|
789
869
|
|
|
790
870
|
|
|
791
871
|
def _placements(value: Dict[str, Any], flag) -> None:
|
|
792
872
|
kids: List[Any] = []
|
|
793
|
-
for
|
|
873
|
+
for key, index in _indexed(_get(value, "publisherKeys")):
|
|
794
874
|
if _contains(kids, _get(key, "kid")):
|
|
795
|
-
flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" %
|
|
875
|
+
flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" % _show(_get(key, "kid")))
|
|
796
876
|
kids.append(_get(key, "kid"))
|
|
797
|
-
for
|
|
798
|
-
agents: List[
|
|
799
|
-
for
|
|
877
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
878
|
+
agents: List[str] = []
|
|
879
|
+
for placement, p in _indexed(_get(machine, "packages")):
|
|
800
880
|
at = "/machines/%d/packages/%d" % (m, p)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
881
|
+
key = _key_of(_get(placement, "agentRef"), _context_of(placement))
|
|
882
|
+
if key in agents:
|
|
883
|
+
flag("%s/agentRef" % at, "%s is placed twice on one machine in the context %s"
|
|
884
|
+
% (_show(_get(placement, "agentRef")), _show(_context_of(placement))))
|
|
885
|
+
agents.append(key)
|
|
804
886
|
if not _contains(kids, _get(placement, "publisherKid")):
|
|
805
|
-
flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" %
|
|
887
|
+
flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" % _show(_get(placement, "publisherKid")))
|
|
806
888
|
|
|
807
889
|
|
|
808
890
|
def _secret_references(value: Dict[str, Any], flag) -> None:
|
|
809
891
|
machine_ids = [_get(m, "id") for m in _list(_get(value, "machines"))]
|
|
810
|
-
for
|
|
811
|
-
placed = [_get(p, "agentRef") for p in _list(_get(machine, "packages"))]
|
|
892
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
893
|
+
placed = [_key_of(_get(p, "agentRef"), _context_of(p)) for p in _list(_get(machine, "packages"))]
|
|
812
894
|
slots: List[str] = []
|
|
813
|
-
for
|
|
895
|
+
for secret, s in _indexed(_get(machine, "secrets")):
|
|
814
896
|
at = "/machines/%d/secrets/%d" % (m, s)
|
|
815
|
-
|
|
897
|
+
# The message never repeats the value, and no message of this rule repeats a secret placement's context.
|
|
898
|
+
for field in ("slot", "from", "agentRef", "context"):
|
|
816
899
|
text = _get(secret, field)
|
|
817
900
|
if isinstance(text, str) and _secrets.secret_value(text):
|
|
818
901
|
flag("%s/%s" % (at, field), "a secret placement names a slot, never a value: this looks like a credential")
|
|
819
|
-
if
|
|
820
|
-
flag("%s/agentRef" % at, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine")
|
|
902
|
+
if _key_of(_get(secret, "agentRef"), _context_of(secret)) not in placed:
|
|
903
|
+
flag("%s/agentRef" % at, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context")
|
|
821
904
|
if not _contains(machine_ids, _get(secret, "from")):
|
|
822
905
|
flag("%s/from" % at, "the slot is filled from a machine of this estate")
|
|
823
|
-
|
|
906
|
+
elif not _same(_context_of(secret), PERSONAL_CONTEXT) and not _same(_get(secret, "from"), _get(machine, "id")):
|
|
907
|
+
# An envelope names no context (DEC-0082): an organization's instance fills its slot on its own machine.
|
|
908
|
+
flag("%s/from" % at, "an organization's instance fills its slot from its own machine's vault, never by an envelope from another machine")
|
|
909
|
+
key = _key_of(_get(secret, "agentRef"), _get(secret, "slot"), _context_of(secret))
|
|
824
910
|
if key in slots:
|
|
825
|
-
flag("%s/slot" % at, "slot %s is placed twice for one agent on one machine" %
|
|
911
|
+
flag("%s/slot" % at, "slot %s is placed twice for one agent in one context on one machine" % _show(_get(secret, "slot")))
|
|
826
912
|
slots.append(key)
|
|
827
913
|
|
|
828
914
|
|
|
@@ -834,12 +920,12 @@ def _grant_key(ident: Any) -> Any:
|
|
|
834
920
|
|
|
835
921
|
def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
836
922
|
grant_ids: List[Any] = []
|
|
837
|
-
for
|
|
923
|
+
for grant, index in _indexed(_get(value, "shareGrants")):
|
|
838
924
|
if _get(grant, "principalKind") != "external":
|
|
839
925
|
flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
|
|
840
926
|
ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
|
|
841
927
|
if _contains(grant_ids, _grant_key(ident)):
|
|
842
|
-
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" %
|
|
928
|
+
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _show(ident))
|
|
843
929
|
grant_ids.append(_grant_key(ident))
|
|
844
930
|
machines = _list(_get(value, "machines"))
|
|
845
931
|
listed = {
|
|
@@ -852,14 +938,60 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
852
938
|
"grant": grant_ids,
|
|
853
939
|
}
|
|
854
940
|
seen: List[str] = []
|
|
855
|
-
for
|
|
941
|
+
for revocation, index in _indexed(_get(value, "revocations")):
|
|
856
942
|
ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
|
|
857
|
-
|
|
943
|
+
kind = _show(_get(revocation, "kind"))
|
|
944
|
+
key = "%s %s" % (kind, _show(ref))
|
|
858
945
|
if key in seen:
|
|
859
|
-
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (
|
|
946
|
+
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (kind, _show(_get(revocation, "ref"))))
|
|
860
947
|
seen.append(key)
|
|
861
|
-
if _contains(listed.get(
|
|
862
|
-
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" %
|
|
948
|
+
if _contains(listed.get(kind, []), ref):
|
|
949
|
+
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % kind)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def grant_context_findings(grant: Any, at: str) -> List[Dict[str, str]]:
|
|
953
|
+
"""FAC-SEM-110 (DEC-0082): a grant to a principal outside the estate (``principalKind: external``, a share grant) is
|
|
954
|
+
used in that principal's organization, so its ``context`` is never ``personal``; absent means not stated."""
|
|
955
|
+
if not isinstance(grant, dict) or _get(grant, "principalKind") != "external" or _get(grant, "context") != PERSONAL_CONTEXT:
|
|
956
|
+
return []
|
|
957
|
+
return [{"code": "FAC-SEM-110", "instancePath": "%s/context" % at,
|
|
958
|
+
"message": "a grant to a principal outside the estate is used in that principal's organization, never in the personal context: "
|
|
959
|
+
"name the organization's org:<orgId>, or leave context out"}]
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
963
|
+
"""FAC-SEM-111 (DEC-0082) for one placement: an instance in an organization's context names, in ``grant``, a share
|
|
964
|
+
grant of the same record (matched by the normal form of its id, DEC-0076) whose ``context`` is the placement's, as
|
|
965
|
+
written, and that the record does not revoke; a personal instance names none. Any other context needs a grant, and
|
|
966
|
+
only an ``org:<orgId>`` context can match one, so a malformed context fails closed."""
|
|
967
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
968
|
+
context = _context_of(placement)
|
|
969
|
+
grant = _get(placement, "grant")
|
|
970
|
+
if _same(context, PERSONAL_CONTEXT):
|
|
971
|
+
return [] if grant is _MISSING else [("/grant", "a personal instance runs under no share grant: only a placement in an organization's context names one")]
|
|
972
|
+
if not isinstance(grant, str):
|
|
973
|
+
return [("/grant", "an instance placed in the context %s names the share grant it runs under, by its id" % _show(context))]
|
|
974
|
+
ident = fabric_identifiers.normal_identifier(grant)
|
|
975
|
+
grants = [g for g in _list(_get(record, "shareGrants")) if isinstance(_get(g, "meta"), dict)
|
|
976
|
+
and isinstance(_get(g["meta"], "id"), str) and fabric_identifiers.normal_identifier(g["meta"]["id"]) == ident]
|
|
977
|
+
problems: List[Tuple[str, str]] = []
|
|
978
|
+
if not grants:
|
|
979
|
+
problems.append(("/grant", "the record has no share grant %s" % grant))
|
|
980
|
+
elif (parse_context(context) or {}).get("kind") != "org":
|
|
981
|
+
problems.append(("/context", "%s is not an organization's context id (org:<orgId>), so no share grant matches it" % _show(context)))
|
|
982
|
+
elif not any(_same(_get(g, "context"), context) for g in grants):
|
|
983
|
+
problems.append(("/grant", "the share grant %s is not for the context %s: its context must be the placement's, as written" % (grant, _show(context))))
|
|
984
|
+
if any(_get(e, "kind") == "grant" and isinstance(_get(e, "ref"), str) and fabric_identifiers.normal_identifier(e["ref"]) == ident
|
|
985
|
+
for e in _list(_get(record, "revocations"))):
|
|
986
|
+
problems.append(("/grant", "the record revokes the share grant %s, so no instance runs under it" % grant))
|
|
987
|
+
return problems
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _placement_grants(value: Dict[str, Any], flag) -> None:
|
|
991
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
992
|
+
for placement, p in _indexed(_get(machine, "packages")):
|
|
993
|
+
for suffix, message in _placement_grant_problems(value, placement):
|
|
994
|
+
flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
|
|
863
995
|
|
|
864
996
|
|
|
865
997
|
def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
@@ -869,7 +1001,10 @@ def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
869
1001
|
_placements(value, _finding(findings, "FAC-SEM-074"))
|
|
870
1002
|
_secret_references(value, _finding(findings, "FAC-SEM-075"))
|
|
871
1003
|
_grants_and_revocations(value, _finding(findings, "FAC-SEM-076"))
|
|
872
|
-
for
|
|
1004
|
+
for grant, index in _indexed(_get(value, "shareGrants")): # FAC-SEM-110 (DEC-0082)
|
|
1005
|
+
findings.extend(grant_context_findings(grant, "/shareGrants/%d" % index))
|
|
1006
|
+
_placement_grants(value, _finding(findings, "FAC-SEM-111"))
|
|
1007
|
+
for authority, index in _indexed(_get(value, "authorities")): # FAC-SEM-092 (DEC-0063)
|
|
873
1008
|
_signing_credential(authority, "/authorities/%d" % index, _finding(findings, "FAC-SEM-092"))
|
|
874
1009
|
return findings
|
|
875
1010
|
|
|
@@ -884,29 +1019,36 @@ def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
884
1019
|
record = value["record"] if isinstance(_get(value, "record"), dict) else {}
|
|
885
1020
|
pkg = value["package"] if isinstance(_get(value, "package"), dict) else {}
|
|
886
1021
|
machines = _list(_get(record, "machines"))
|
|
887
|
-
machine_index = next((i for
|
|
1022
|
+
machine_index = next((i for m, i in _indexed(_get(record, "machines")) if _same(_get(m, "id"), _get(value, "machine"))), -1)
|
|
888
1023
|
flag074 = _finding(findings, "FAC-SEM-074")
|
|
889
1024
|
if machine_index < 0:
|
|
890
|
-
flag074("/machine", "machine %s is not in the estate record" %
|
|
1025
|
+
flag074("/machine", "machine %s is not in the estate record" % _show(_get(value, "machine")))
|
|
891
1026
|
return findings
|
|
892
|
-
machine = machines[machine_index]
|
|
1027
|
+
machine = record["machines"][machine_index]
|
|
893
1028
|
try:
|
|
894
1029
|
digest: Any = _package_digest(pkg)
|
|
895
1030
|
except TypeError:
|
|
896
1031
|
digest = _MISSING
|
|
897
1032
|
if digest is _MISSING or not _same(digest, _get(pkg, "digest")):
|
|
898
1033
|
flag074("/package/digest", "the package is not the one its digest names: it changed after it was packed")
|
|
899
|
-
|
|
1034
|
+
context = _context_of(value)
|
|
1035
|
+
placement, placement_index = next(((p, i) for p, i in _indexed(_get(machine, "packages"))
|
|
1036
|
+
if _same(_get(p, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(p), context)), (None, -1))
|
|
900
1037
|
if placement is None:
|
|
901
|
-
flag074("/package/agentRef", "the record places no %s on machine %s
|
|
1038
|
+
flag074("/package/agentRef", "the record places no %s on machine %s in the context %s"
|
|
1039
|
+
% (_show(_get(pkg, "agentRef")), _show(_get(value, "machine")), _show(context)))
|
|
902
1040
|
elif not _same(_get(placement, "digest"), digest):
|
|
903
|
-
flag074("/package", "the record places %s of this agent on this machine, not this package" %
|
|
1041
|
+
flag074("/package", "the record places %s of this agent on this machine in this context, not this package" % _show(_get(placement, "digest")))
|
|
904
1042
|
declared = [_get(slot, "name") for slot in _list(_get(pkg, "secrets"))]
|
|
905
1043
|
flag075 = _finding(findings, "FAC-SEM-075")
|
|
906
|
-
for
|
|
907
|
-
if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and not _contains(declared, _get(secret, "slot")):
|
|
1044
|
+
for secret, index in _indexed(_get(machine, "secrets")):
|
|
1045
|
+
if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(secret), context) and not _contains(declared, _get(secret, "slot")):
|
|
908
1046
|
flag075("/record/machines/%d/secrets/%d/slot" % (machine_index, index),
|
|
909
|
-
"the package declares no slot %s: a secret goes only where a placed package declares it" %
|
|
1047
|
+
"the package declares no slot %s: a secret goes only where a placed package declares it" % _show(_get(secret, "slot")))
|
|
1048
|
+
if placement is not None:
|
|
1049
|
+
flag111 = _finding(findings, "FAC-SEM-111")
|
|
1050
|
+
for suffix, message in _placement_grant_problems(record, placement):
|
|
1051
|
+
flag111("/record/machines/%d/packages/%d%s" % (machine_index, placement_index, suffix), message)
|
|
910
1052
|
return findings
|
|
911
1053
|
|
|
912
1054
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -49,9 +49,10 @@ DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensio
|
|
|
49
49
|
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.0"
|
|
50
50
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
51
51
|
EXIT_ALREADY_RUNNING = 75
|
|
52
|
-
# Lifecycle
|
|
53
|
-
#
|
|
54
|
-
#
|
|
52
|
+
# Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
|
|
53
|
+
# contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
|
|
54
|
+
# off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
|
|
55
|
+
# that is a few starts an hour instead of one every 10 s.
|
|
55
56
|
SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
|
|
56
57
|
LOCK_WAIT_SUPERVISED_SECONDS = 300.0
|
|
57
58
|
LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
@@ -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.33.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "82e163665e156a08aa3b92e4711015582c06162c"
|
|
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 `82e163665e156a08aa3b92e4711015582c06162c` 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
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# The project board (`fabric-project-comms/0.1`, the `com.*` tools)
|
|
2
2
|
|
|
3
3
|
Read this when the session starts in a project a Fabric hub serves, when the person asks about board mail, when you
|
|
4
|
-
need something from another Project, or when you wait for an answer.
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
need something from another Project, or when you wait for an answer. This page is the rule a coding agent with this
|
|
5
|
+
plugin follows. Inside PassionCode.ai it implements the knowledge base's
|
|
6
|
+
[agents-first.md, "The project board"](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/agents-first.md#the-project-board)
|
|
7
|
+
(private to the organization), and is kept in step with it; nothing behind that link is needed to follow this page.
|
|
7
8
|
|
|
8
9
|
## Contents
|
|
9
10
|
|
|
@@ -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.33.0"
|
|
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
|