@passioncode-ai/fabric-agent-adapter 0.23.0 → 0.24.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 +46 -0
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- 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,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
An app's own descriptor (contract DEC-0069) and the descriptor for listings (DEC-0061) in both kits (FAA-34). The contract
|
|
6
|
+
pin does not move: both decisions are at `fe55fc6`.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`lifecycle.manager` `node` and `person`.** `node`: Project Observatory's node supervises the service as its child,
|
|
11
|
+
with no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it. `person`: an app a person
|
|
12
|
+
installs and starts — `lifecycle.app` is required, `origin` is optional (its live origin is in the app's own liveness
|
|
13
|
+
file), and `installedBy` is `{by: "person", role, tool}`. `write_descriptor` / `writeDescriptor` claim no port for
|
|
14
|
+
it, and `check_service.py` reads it as installed, never running: `descriptor.app-installed`, no port claim, and the
|
|
15
|
+
well-known document `NOT_RUN`.
|
|
16
|
+
- **`installedBy` by role, `runner` and `peer`.**
|
|
17
|
+
- `installedBy` is a string or `{by, role?, tool}`.
|
|
18
|
+
- `runner` is `{kind?, credentialKind}`, a kind and never a credential.
|
|
19
|
+
- `peer` is `{endpoint, custody, residency, maxDataClass}`. An offer needs `runner` and never a subscription.
|
|
20
|
+
- FAC-SEM-085 refuses a peer on a reserved name. FAC-SEM-086 refuses a value shaped like a credential in `runner` or
|
|
21
|
+
`installedBy`, naming its place.
|
|
22
|
+
- **Ports held to the contract's vectors:** `peer_refusal` / `peerRefusal` (`service-peer.json`),
|
|
23
|
+
`app_descriptor_action` / `appDescriptorAction` and `app_descriptor_stale` / `appDescriptorStale`
|
|
24
|
+
(`app-descriptor.json`).
|
|
25
|
+
- **`fabric_button_presence` / `fabricButtonPresence`** answer whether Fabric's button may show. The answer is
|
|
26
|
+
`present` only while Fabric's own descriptor (`fabric.default.json`) is registered and, for an app's descriptor,
|
|
27
|
+
its app is still installed. A server sets `data-fabric="absent"` otherwise.
|
|
28
|
+
- **Usage:** `make_usage_receipt` / `makeUsageReceipt` accept `route: "platform-metered"`. A configuration's route
|
|
29
|
+
stays `subscription` or `key`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `register_remote` / `registerRemote` and the sample's `register` write `installedBy` in its object form
|
|
34
|
+
(`{by: "person", role: "operator", tool}`). `register_remote` / `registerRemote` also take `runner` and `peer`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `check_service.py` reported two descriptors without an origin as claiming the same port.
|
|
39
|
+
- `write_descriptor` / `writeDescriptor` failed on a descriptor without one.
|
|
40
|
+
|
|
41
|
+
### Evidence
|
|
42
|
+
|
|
43
|
+
- Every service-descriptor fixture of the contract at `fe55fc6` is held through both validators: 10 valid accepted;
|
|
44
|
+
every invalid one refused except the six local-channel negatives (DEC-0075, not a kit surface), a set the tests pin.
|
|
45
|
+
- The FAC-SEM-085/086 semantic fixtures answer at the contract's places.
|
|
46
|
+
- 32 planted defects, all caught.
|
|
47
|
+
- The scenario eval `app-descriptor-and-listing` was written before the prose.
|
|
48
|
+
|
|
3
49
|
## 0.23.0 - 2026-10-13
|
|
4
50
|
|
|
5
51
|
The contract pin moves from `b291bf0` (DEC-0055) to contract `main` `fe55fc6`, and both kits follow every rule in that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.24.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ 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.24.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
|
|
11
11
|
---
|
|
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
16
16
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
17
17
|
metadata:
|
|
18
18
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.24.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
22
|
extension-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
|
|
@@ -323,8 +323,11 @@ this estate needs.
|
|
|
323
323
|
|
|
324
324
|
## When something is missing
|
|
325
325
|
|
|
326
|
-
- **Not macOS / no launchd:**
|
|
327
|
-
service read-only. Keep every other rule.
|
|
326
|
+
- **Not macOS / no launchd:** the kits write a systemd user unit (Linux) or a per-user Scheduled Task (Windows); with
|
|
327
|
+
no supervisor at all declare `lifecycle.manager: "none"`, and hosts show the service read-only. Keep every other rule.
|
|
328
|
+
- **A desktop app with no supervisor** (DEC-0069): its descriptor says `manager: "person"` with `lifecycle.app`, no
|
|
329
|
+
origin, `installedBy {by: "person", role, tool}`; its live origin is in its own liveness file
|
|
330
|
+
(the lifecycle reference, *An app's own descriptor and the node*).
|
|
328
331
|
- **No Python:** use the Node kit; run the probe from any machine that has Python and
|
|
329
332
|
can reach the port, or mark the probe `NOT_RUN` with the reason.
|
|
330
333
|
- **Neither kit fits the framework:** implement the four routes and the lock by hand
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
CHANGED
|
@@ -100,10 +100,13 @@ service. Load it after the stylesheet, with ids only:
|
|
|
100
100
|
— or one shaped like a credential — means no button, never a bad link. A press opens a view in Fabric and starts
|
|
101
101
|
nothing (FB-8).
|
|
102
102
|
- **The host (FB-6):** when the host's `fabric_button` cookie says `owner=host`, the page hides its own button.
|
|
103
|
-
- **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
- **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"`, or when the page is still
|
|
104
|
+
visible and focused 1.5 s after a link was followed, the menu says "Fabric is not installed — get it at
|
|
105
|
+
passioncode.ai/fabric". Nothing downloads, nothing retries. A local service's server decides `data-fabric` with
|
|
106
|
+
`fabric_button_presence()` / `fabricButtonPresence()`: `present` while Fabric's own descriptor
|
|
107
|
+
(`services/fabric.default.json`, Fabric ADR-0139) is registered and, for an app's descriptor (`manager: "person"`,
|
|
108
|
+
DEC-0069), its app is still installed; `absent` otherwise. A press only follows a link, so the button never reads
|
|
109
|
+
Fabric's liveness file; a caller that sends Fabric anything reads that file and checks the process first.
|
|
107
110
|
|
|
108
111
|
`window.FabricButton` exposes `buildFabricLink`, `parseFabricLink` and `parseButtonCookie` for a page that needs
|
|
109
112
|
them. The button is optional: a page without the script has no button.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -104,6 +104,22 @@ never infers supervision from its parent process.
|
|
|
104
104
|
starts against a held lock are recorded and read as `FAC-SEM-078` reads them
|
|
105
105
|
(`test/*supervisors*`).
|
|
106
106
|
|
|
107
|
+
## An app's own descriptor and the node
|
|
108
|
+
|
|
109
|
+
**An app** a person installs and starts has no supervisor (contract DEC-0069). Its descriptor says
|
|
110
|
+
`"lifecycle": {"manager": "person", "app": "<the installed bundle or executable>"}`, `installedBy {by: "person", role,
|
|
111
|
+
tool}` and no `origin`: it says the app is installed, never that it runs. The installer writes it; where there is none
|
|
112
|
+
(a macOS app dragged into Applications), the app writes it on a start when `app_descriptor_action` /
|
|
113
|
+
`appDescriptorAction` answers `write` — no descriptor yet, another version in `installedBy.tool`, or another path than
|
|
114
|
+
`lifecycle.app` — and never from an App Translocation path. Its live origin and process id go in its own liveness file,
|
|
115
|
+
rewritten on every start and removed on quit; a caller that sends anything reads that file and checks the process.
|
|
116
|
+
`write_descriptor` claims no port for it, and a reader that finds `lifecycle.app` gone treats the descriptor as stale.
|
|
117
|
+
|
|
118
|
+
**The node** (DEC-0061): `"lifecycle": {"manager": "node"}` is a local service Project Observatory's node installed and
|
|
119
|
+
supervises as its child. There is no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it; the
|
|
120
|
+
node starts the child with the supervisor marker (`--fabric-supervisor node` or the variable) and owns Install, Off,
|
|
121
|
+
Code, Uninstall and state.
|
|
122
|
+
|
|
107
123
|
## Directories
|
|
108
124
|
|
|
109
125
|
| What | macOS | Linux | Windows (DEC-0032) |
|
|
@@ -206,7 +222,9 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
|
|
|
206
222
|
A task cannot set an environment variable, so `task_xml` / `taskXml` write the marker as an
|
|
207
223
|
argument, `--fabric-supervisor task-scheduler`, before any `--`: a copy the Task Scheduler starts
|
|
208
224
|
backs off in-process like one under launchd or systemd. `IgnoreNew` keeps the scheduler from
|
|
209
|
-
starting a second copy.
|
|
225
|
+
starting a second copy. Measured (FAA-38, [report](https://github.com/passioncode-ai/fabric-agent-adapter/blob/main/docs/reports/2026-10-13-faa-38-windows-exit75/README.md)):
|
|
226
|
+
`RestartOnFailure` does not restart a task whose action exits 75, so that wait is the only recovery there; a copy
|
|
227
|
+
that still exits 75 stays down until the next logon.
|
|
210
228
|
|
|
211
229
|
**One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
|
|
212
230
|
(Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
|
|
@@ -31,13 +31,15 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
31
31
|
| `protocol` | `"fabric-service/0.1"` |
|
|
32
32
|
| `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique per machine |
|
|
33
33
|
| `name`, `summary` | ≤ 80 and ≤ 200 characters |
|
|
34
|
-
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
|
|
34
|
+
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim. Optional for an app's descriptor only (`manager: "person"`, DEC-0069): its live origin and process id are in the app's own liveness file, and a descriptor without an origin claims no port (`FAC-SEM-010`) |
|
|
35
35
|
| `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
|
|
36
|
-
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032)
|
|
36
|
+
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
|
|
37
37
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
38
38
|
| `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
|
|
39
39
|
| `source.repository`, `fabricManifest` | optional |
|
|
40
|
-
| `installedAt`, `installedBy` | when and by
|
|
40
|
+
| `installedAt`, `installedBy` | when, and who installed it: the installer and its version as a string, or `{by, role?, tool}` (DEC-0061) — `by` `person` (with `role` `operator` or `administrator`), `node` or `organization`; `role` for a person only; `tool` the installer and its version. It names a role, never a person. An app's descriptor uses the object form with `by: "person"`. `register_remote` / `registerRemote` and the sample write the object form |
|
|
41
|
+
| `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none` — a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
|
|
42
|
+
| `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed` or `personal`; `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
|
|
41
43
|
|
|
42
44
|
## Well-known document — `GET /.well-known/fabric-service`
|
|
43
45
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -189,10 +189,23 @@ class Probe:
|
|
|
189
189
|
continue
|
|
190
190
|
if key == me:
|
|
191
191
|
clashes.append("%s declared again in %s" % (me, path.name))
|
|
192
|
-
|
|
192
|
+
# DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
|
|
193
|
+
elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
|
|
194
|
+
and other.get("origin") == self.d.get("origin")):
|
|
193
195
|
clashes.append("port %d also claimed by %s" % (self.port, key))
|
|
194
|
-
|
|
196
|
+
if self.remote:
|
|
197
|
+
unique = "a remote origin claims no port here"
|
|
198
|
+
elif "origin" not in self.d:
|
|
199
|
+
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
200
|
+
else:
|
|
201
|
+
unique = "port %d is unique" % self.port
|
|
195
202
|
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
203
|
+
if (self.d.get("lifecycle") or {}).get("manager") == "person":
|
|
204
|
+
# DEC-0069: installed, never running — a reader checks the app is still there; liveness is the app's own file.
|
|
205
|
+
present = fs.app_present(self.d)
|
|
206
|
+
self.add("descriptor.app-installed", "PASS" if present else "FAIL",
|
|
207
|
+
"%s is installed" % self.d["lifecycle"].get("app") if present
|
|
208
|
+
else "%s is gone: a reader treats this descriptor as stale" % self.d["lifecycle"].get("app"))
|
|
196
209
|
|
|
197
210
|
# well-known ----------------------------------------------------------------
|
|
198
211
|
def well_known_rules(self) -> None:
|
|
@@ -762,6 +775,10 @@ class Probe:
|
|
|
762
775
|
|
|
763
776
|
def run(self) -> List[Result]:
|
|
764
777
|
self.descriptor_rules()
|
|
778
|
+
if "origin" not in self.d and not self.remote:
|
|
779
|
+
self.add("well-known.answers", "NOT_RUN",
|
|
780
|
+
"an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file (DEC-0069)")
|
|
781
|
+
return self.results
|
|
765
782
|
if not self.port:
|
|
766
783
|
return self.results
|
|
767
784
|
self.well_known_rules()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -54,7 +54,11 @@ const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from f
|
|
|
54
54
|
// DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
|
|
55
55
|
// DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
|
|
56
56
|
const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
|
|
57
|
-
|
|
57
|
+
// DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app a
|
|
58
|
+
// person installs and starts; its descriptor says it is installed, never that it runs).
|
|
59
|
+
export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node', 'person', 'none']);
|
|
60
|
+
/** Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069). */
|
|
61
|
+
const NOT_LAUNCHD = Object.freeze(['systemd', 'task-scheduler', 'node', 'person']);
|
|
58
62
|
const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
|
|
59
63
|
const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
|
|
60
64
|
const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
|
|
@@ -521,7 +525,9 @@ export function validateDescriptor(d) {
|
|
|
521
525
|
const problems = [];
|
|
522
526
|
const remote = placementOf(d) === 'remote';
|
|
523
527
|
if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
|
|
524
|
-
|
|
528
|
+
// DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
529
|
+
const app = d.lifecycle?.manager === 'person';
|
|
530
|
+
const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
|
|
525
531
|
for (const key of remote ? required : [...required, 'paths']) {
|
|
526
532
|
if (!(key in d)) problems.push(`missing ${key}`);
|
|
527
533
|
}
|
|
@@ -530,7 +536,7 @@ export function validateDescriptor(d) {
|
|
|
530
536
|
if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
|
|
531
537
|
if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
|
|
532
538
|
if (remote) problems.push(...remoteOriginProblems(d.origin));
|
|
533
|
-
else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
539
|
+
else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
534
540
|
if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
|
|
535
541
|
else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
|
|
536
542
|
for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
|
|
@@ -543,14 +549,18 @@ export function validateDescriptor(d) {
|
|
|
543
549
|
}
|
|
544
550
|
const life = d.lifecycle ?? {};
|
|
545
551
|
const manager = life.manager;
|
|
546
|
-
if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
|
|
552
|
+
if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
|
|
553
|
+
// DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
554
|
+
if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
|
|
555
|
+
if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
|
|
556
|
+
if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
|
|
547
557
|
if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
|
|
548
558
|
problems.push('a launchd service declares label and plist');
|
|
549
559
|
}
|
|
550
560
|
if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
|
|
551
|
-
// DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
561
|
+
// DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
552
562
|
for (const field of ['label', 'plist']) {
|
|
553
|
-
if (life[field] !== undefined &&
|
|
563
|
+
if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
|
|
554
564
|
}
|
|
555
565
|
// DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
556
566
|
for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
|
|
@@ -565,6 +575,7 @@ export function validateDescriptor(d) {
|
|
|
565
575
|
}
|
|
566
576
|
if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
|
|
567
577
|
}
|
|
578
|
+
problems.push(...descriptorMemberProblems(d));
|
|
568
579
|
for (const [name, argv] of Object.entries(d.commands ?? {})) {
|
|
569
580
|
if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
|
|
570
581
|
else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
|
|
@@ -574,6 +585,127 @@ export function validateDescriptor(d) {
|
|
|
574
585
|
}
|
|
575
586
|
|
|
576
587
|
/** DEC-0019: `local` unless the descriptor says `remote`. */
|
|
588
|
+
// --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
|
|
589
|
+
|
|
590
|
+
/** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
|
|
591
|
+
export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
|
|
592
|
+
/** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
|
|
593
|
+
export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
|
|
594
|
+
/** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
|
|
595
|
+
export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
|
|
596
|
+
const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
|
|
597
|
+
const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
|
|
598
|
+
const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
|
|
599
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
600
|
+
const text = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
|
|
601
|
+
/** A JSON Pointer token (RFC 6901 §4). */
|
|
602
|
+
const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
|
|
606
|
+
* (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
|
|
607
|
+
* place, never its value).
|
|
608
|
+
*/
|
|
609
|
+
export function descriptorMemberProblems(d) {
|
|
610
|
+
const problems = [];
|
|
611
|
+
const life = isObj(d.lifecycle) ? d.lifecycle : {};
|
|
612
|
+
const by = d.installedBy;
|
|
613
|
+
const places = [];
|
|
614
|
+
if (typeof by === 'string') {
|
|
615
|
+
if (!text(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
|
|
616
|
+
places.push(['/installedBy', by]);
|
|
617
|
+
} else if (isObj(by)) {
|
|
618
|
+
for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
|
|
619
|
+
if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
|
|
620
|
+
if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
|
|
621
|
+
if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
|
|
622
|
+
if (!text(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
|
|
623
|
+
for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
|
|
624
|
+
} else problems.push("installedBy is the installer's name or {by, role?, tool}");
|
|
625
|
+
if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
|
|
626
|
+
let runner = d.runner;
|
|
627
|
+
if ('runner' in d) {
|
|
628
|
+
if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
|
|
629
|
+
for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
|
|
630
|
+
if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
|
|
631
|
+
if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
|
|
632
|
+
if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
|
|
633
|
+
for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
|
|
634
|
+
}
|
|
635
|
+
if ('peer' in d) {
|
|
636
|
+
let peer = d.peer;
|
|
637
|
+
if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
|
|
638
|
+
for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
|
|
639
|
+
const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
|
|
640
|
+
if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
|
|
641
|
+
else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
|
|
642
|
+
if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
|
|
643
|
+
const r = peer.residency;
|
|
644
|
+
if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
|
|
645
|
+
problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
|
|
646
|
+
}
|
|
647
|
+
if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
|
|
648
|
+
if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
|
|
649
|
+
else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
|
|
650
|
+
}
|
|
651
|
+
for (const [place, value] of places) {
|
|
652
|
+
if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
|
|
653
|
+
}
|
|
654
|
+
return problems;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
659
|
+
* `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
|
|
660
|
+
*/
|
|
661
|
+
export function peerRefusal(descriptor, dataClass) {
|
|
662
|
+
if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
|
|
663
|
+
const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
|
|
664
|
+
if (max < 0) return 'peer-invalid';
|
|
665
|
+
const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
|
|
666
|
+
if (asked < 0) return 'data-class-unknown';
|
|
667
|
+
return asked > max ? 'above-max-data-class' : null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
|
|
672
|
+
* App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
|
|
673
|
+
* (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
|
|
674
|
+
*/
|
|
675
|
+
export function appDescriptorAction(existing, tool, appPath) {
|
|
676
|
+
if (appPath.includes('/AppTranslocation/')) return 'keep';
|
|
677
|
+
if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
|
|
678
|
+
const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
|
|
679
|
+
return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
|
|
683
|
+
export function appDescriptorStale(descriptor, appPresent) {
|
|
684
|
+
if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
|
|
685
|
+
return !appPresent;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
|
|
689
|
+
export function appPresent(descriptor) {
|
|
690
|
+
const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
|
|
691
|
+
try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
|
|
695
|
+
export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
699
|
+
* descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
|
|
700
|
+
* on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
|
|
701
|
+
*/
|
|
702
|
+
export function fabricButtonPresence(dir = servicesDir()) {
|
|
703
|
+
let found;
|
|
704
|
+
try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
|
|
705
|
+
if (!isObj(found) || validateDescriptor(found).length) return 'absent';
|
|
706
|
+
return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
|
|
707
|
+
}
|
|
708
|
+
|
|
577
709
|
export function placementOf(d) {
|
|
578
710
|
return d?.placement === 'remote' ? 'remote' : 'local';
|
|
579
711
|
}
|
|
@@ -597,8 +729,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
|
|
|
597
729
|
const problems = validateDescriptor(d);
|
|
598
730
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
599
731
|
const me = `${d.id}.${d.instance}`;
|
|
600
|
-
// DEC-0019: only a local placement claims a port on this computer.
|
|
601
|
-
const port = placementOf(d) === 'remote' ? null : portOf(d.origin);
|
|
732
|
+
// DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
733
|
+
const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
|
|
602
734
|
for (const [file, other] of readDescriptors(dir)) {
|
|
603
735
|
const key = `${other.id}.${other.instance ?? 'default'}`;
|
|
604
736
|
if (key === me) continue;
|
|
@@ -661,6 +793,8 @@ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
|
661
793
|
const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
|
|
662
794
|
const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
|
|
663
795
|
export const ROUTES = Object.freeze(['subscription', 'key']);
|
|
796
|
+
/** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
|
|
797
|
+
export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
|
|
664
798
|
|
|
665
799
|
/** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
|
|
666
800
|
export function configurationProblems(doc) {
|
|
@@ -766,7 +900,7 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
|
|
|
766
900
|
if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
|
|
767
901
|
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
768
902
|
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
769
|
-
if (route !== undefined && !
|
|
903
|
+
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
770
904
|
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
|
|
771
905
|
}
|
|
772
906
|
|
|
@@ -1650,13 +1784,20 @@ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
|
1650
1784
|
* a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
|
|
1651
1785
|
* never from an argument vector.
|
|
1652
1786
|
*/
|
|
1653
|
-
|
|
1787
|
+
/**
|
|
1788
|
+
* DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
|
|
1789
|
+
* descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
|
|
1790
|
+
* (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
|
|
1791
|
+
* credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
|
|
1792
|
+
*/
|
|
1793
|
+
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
|
|
1794
|
+
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
|
|
1654
1795
|
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
1655
1796
|
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
1656
1797
|
const d = {
|
|
1657
1798
|
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
1658
1799
|
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
1659
|
-
installedAt: nowIso(), installedBy,
|
|
1800
|
+
installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
|
|
1660
1801
|
};
|
|
1661
1802
|
const problems = validateDescriptor(d);
|
|
1662
1803
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -90,7 +90,11 @@ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
|
90
90
|
# DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
|
|
91
91
|
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
|
|
92
92
|
# DEC-0032: the supervisor per system and the field that names its job.
|
|
93
|
-
|
|
93
|
+
# DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
|
|
94
|
+
# a person installs and starts; its descriptor says it is installed, never that it runs).
|
|
95
|
+
MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
|
|
96
|
+
#: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
|
|
97
|
+
_NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
|
|
94
98
|
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
95
99
|
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
|
|
96
100
|
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
|
|
@@ -886,7 +890,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
886
890
|
remote = placement_of(descriptor) == "remote"
|
|
887
891
|
if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
|
|
888
892
|
problems.append("placement must be local or remote")
|
|
889
|
-
|
|
893
|
+
life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
894
|
+
app = life_in.get("manager") == "person"
|
|
895
|
+
# DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
896
|
+
required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
|
|
890
897
|
if not remote:
|
|
891
898
|
required = required + ("paths",)
|
|
892
899
|
for key in required:
|
|
@@ -902,7 +909,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
902
909
|
problems.append("instance must match %s" % _INSTANCE.pattern)
|
|
903
910
|
if remote:
|
|
904
911
|
problems.extend(remote_origin_problems(descriptor["origin"]))
|
|
905
|
-
elif not _ORIGIN.match(str(descriptor["origin"])):
|
|
912
|
+
elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
|
|
906
913
|
problems.append("origin must be http://127.0.0.1:<port>")
|
|
907
914
|
auth = descriptor.get("auth") or {}
|
|
908
915
|
if "tokenFile" not in auth:
|
|
@@ -924,14 +931,21 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
924
931
|
life = descriptor.get("lifecycle") or {}
|
|
925
932
|
manager = life.get("manager")
|
|
926
933
|
if manager not in MANAGERS:
|
|
927
|
-
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
|
|
934
|
+
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
|
|
935
|
+
# DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
936
|
+
if manager == "person" and "app" not in life:
|
|
937
|
+
problems.append("a person's app declares lifecycle.app, the installed app")
|
|
938
|
+
if "app" in life and manager != "person":
|
|
939
|
+
problems.append("lifecycle.app belongs to manager person only")
|
|
940
|
+
if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
|
|
941
|
+
problems.append("lifecycle.app must be a local path")
|
|
928
942
|
if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
929
943
|
problems.append("a launchd service declares label and plist")
|
|
930
944
|
if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
|
|
931
945
|
problems.append("lifecycle.plist must be a local path")
|
|
932
|
-
# DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
946
|
+
# DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
933
947
|
for field in ("label", "plist"):
|
|
934
|
-
if field in life and manager in
|
|
948
|
+
if field in life and manager in _NOT_LAUNCHD:
|
|
935
949
|
problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
|
|
936
950
|
# DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
937
951
|
for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
|
|
@@ -949,6 +963,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
949
963
|
problems.append("a remote service has no %s %s" % (of, field))
|
|
950
964
|
if "update" in (descriptor.get("commands") or {}):
|
|
951
965
|
problems.append("a remote service declares no update command")
|
|
966
|
+
problems.extend(descriptor_member_problems(descriptor))
|
|
952
967
|
for name, argv in (descriptor.get("commands") or {}).items():
|
|
953
968
|
if name not in ("doctor", "update"):
|
|
954
969
|
problems.append("unknown command %s" % name)
|
|
@@ -959,6 +974,166 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
959
974
|
return problems
|
|
960
975
|
|
|
961
976
|
|
|
977
|
+
# --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
|
|
978
|
+
|
|
979
|
+
#: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
|
|
980
|
+
PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
|
|
981
|
+
#: The kind of a runner's sign-in, never a credential's value (DEC-0061).
|
|
982
|
+
CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
|
|
983
|
+
#: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
|
|
984
|
+
SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
|
|
985
|
+
_PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
|
|
986
|
+
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
|
|
987
|
+
_RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _pointer(name: str) -> str:
|
|
991
|
+
"""A JSON Pointer token (RFC 6901 §4)."""
|
|
992
|
+
return str(name).replace("~", "~0").replace("/", "~1")
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _text(value: Any, low: int = 1, high: int = 200) -> bool:
|
|
996
|
+
return isinstance(value, str) and low <= len(value) <= high
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
1000
|
+
"""DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
|
|
1001
|
+
FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
|
|
1002
|
+
named by its place, never its value)."""
|
|
1003
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
1004
|
+
problems: List[str] = []
|
|
1005
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
1006
|
+
by = descriptor.get("installedBy")
|
|
1007
|
+
places: List[Tuple[str, Any]] = []
|
|
1008
|
+
if isinstance(by, str):
|
|
1009
|
+
if not _text(by):
|
|
1010
|
+
problems.append("installedBy is the installer and its version, 1 to 200 characters")
|
|
1011
|
+
places.append(("/installedBy", by))
|
|
1012
|
+
elif isinstance(by, dict):
|
|
1013
|
+
problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
|
|
1014
|
+
if by.get("by") not in ("person", "node", "organization"):
|
|
1015
|
+
problems.append("installedBy.by is person, node or organization")
|
|
1016
|
+
if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
|
|
1017
|
+
problems.append("installedBy.role is operator or administrator for a person")
|
|
1018
|
+
if by.get("by") != "person" and "role" in by:
|
|
1019
|
+
problems.append("installedBy.role belongs to a person only")
|
|
1020
|
+
if not _text(by.get("tool")):
|
|
1021
|
+
problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
|
|
1022
|
+
places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
|
|
1023
|
+
else:
|
|
1024
|
+
problems.append("installedBy is the installer's name or {by, role?, tool}")
|
|
1025
|
+
if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
|
|
1026
|
+
problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
|
|
1027
|
+
runner = descriptor.get("runner")
|
|
1028
|
+
if "runner" in descriptor:
|
|
1029
|
+
if not isinstance(runner, dict):
|
|
1030
|
+
problems.append("runner is {kind?, credentialKind}")
|
|
1031
|
+
runner = {}
|
|
1032
|
+
problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
|
|
1033
|
+
if runner.get("credentialKind") not in CREDENTIAL_KINDS:
|
|
1034
|
+
problems.append("runner.credentialKind is subscription, key, platform-metered or none")
|
|
1035
|
+
if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
|
|
1036
|
+
problems.append("runner.kind is a runner kind's name")
|
|
1037
|
+
if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
|
|
1038
|
+
problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
|
|
1039
|
+
places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
|
|
1040
|
+
if "peer" in descriptor:
|
|
1041
|
+
peer = descriptor.get("peer")
|
|
1042
|
+
if not isinstance(peer, dict):
|
|
1043
|
+
problems.append("peer is {endpoint, custody, residency, maxDataClass}")
|
|
1044
|
+
peer = {}
|
|
1045
|
+
problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
|
|
1046
|
+
endpoint = peer.get("endpoint")
|
|
1047
|
+
match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
|
|
1048
|
+
if not match:
|
|
1049
|
+
problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
|
|
1050
|
+
elif _RESERVED_HOST.search(match.group("host")):
|
|
1051
|
+
problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
|
|
1052
|
+
if peer.get("custody") not in ("organization-managed", "personal"):
|
|
1053
|
+
problems.append("peer.custody is organization-managed or personal")
|
|
1054
|
+
residency = peer.get("residency")
|
|
1055
|
+
if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
|
|
1056
|
+
or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
|
|
1057
|
+
problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
|
|
1058
|
+
if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
|
|
1059
|
+
problems.append("peer.maxDataClass is public, internal, confidential or restricted")
|
|
1060
|
+
if "runner" not in descriptor:
|
|
1061
|
+
problems.append("a service offered to others declares runner (DEC-0061)")
|
|
1062
|
+
elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
|
|
1063
|
+
problems.append("a service offered to others never runs on a subscription (DEC-0061)")
|
|
1064
|
+
for place, value in places:
|
|
1065
|
+
if isinstance(value, str) and fabric_secrets.secret_argument(value):
|
|
1066
|
+
problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
|
|
1067
|
+
return problems
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
|
|
1071
|
+
"""The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
1072
|
+
`not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
|
|
1073
|
+
request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
|
|
1074
|
+
`peerRefusal`."""
|
|
1075
|
+
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
|
|
1076
|
+
return "not-offered"
|
|
1077
|
+
ceiling = descriptor["peer"].get("maxDataClass")
|
|
1078
|
+
if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
|
|
1079
|
+
return "peer-invalid"
|
|
1080
|
+
if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
|
|
1081
|
+
return "data-class-unknown"
|
|
1082
|
+
return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
|
|
1086
|
+
"""What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
|
|
1087
|
+
translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
|
|
1088
|
+
when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
|
|
1089
|
+
`appDescriptorAction`."""
|
|
1090
|
+
if "/AppTranslocation/" in app_path:
|
|
1091
|
+
return "keep"
|
|
1092
|
+
if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
|
|
1093
|
+
return "write"
|
|
1094
|
+
recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
|
|
1095
|
+
return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
_MISSING_APP = object()
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
|
|
1102
|
+
"""Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
|
|
1103
|
+
A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
|
|
1104
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
|
|
1105
|
+
return isinstance(life, dict) and life.get("manager") == "person" and not app_present
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def app_present(descriptor: Dict[str, Any]) -> bool:
|
|
1109
|
+
"""Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
|
|
1110
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
1111
|
+
app = life.get("app")
|
|
1112
|
+
try:
|
|
1113
|
+
return isinstance(app, str) and expand(app).exists()
|
|
1114
|
+
except ServiceError: # not a local path: nothing installed there
|
|
1115
|
+
return False
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
#: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
|
|
1119
|
+
FABRIC_SERVICE = ("fabric", "default")
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def fabric_button_presence(directory: Optional[Path] = None) -> str:
|
|
1123
|
+
"""`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
1124
|
+
descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
|
|
1125
|
+
`data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
|
|
1126
|
+
not read here; a caller that sends anything reads it."""
|
|
1127
|
+
path = descriptor_path(*FABRIC_SERVICE, directory=directory)
|
|
1128
|
+
try:
|
|
1129
|
+
found = json.loads(path.read_text(encoding="utf-8"))
|
|
1130
|
+
except (OSError, ValueError):
|
|
1131
|
+
return "absent"
|
|
1132
|
+
if not isinstance(found, dict) or validate_descriptor(found):
|
|
1133
|
+
return "absent"
|
|
1134
|
+
return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
|
|
1135
|
+
|
|
1136
|
+
|
|
962
1137
|
def placement_of(descriptor: Dict[str, Any]) -> str:
|
|
963
1138
|
"""DEC-0019: ``local`` unless the descriptor says ``remote``."""
|
|
964
1139
|
return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
|
|
@@ -990,11 +1165,11 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
|
|
|
990
1165
|
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
991
1166
|
root = directory or services_dir()
|
|
992
1167
|
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
993
|
-
# DEC-0019: only a local placement claims a port on this computer.
|
|
994
|
-
port = None if placement_of(descriptor) == "remote" else port_of(descriptor["origin"])
|
|
1168
|
+
# DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
1169
|
+
port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
|
|
995
1170
|
for path, other in read_descriptors(root):
|
|
996
1171
|
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
997
|
-
if key == me or port is None: # a remote origin's port is another computer's
|
|
1172
|
+
if key == me or port is None: # a remote origin's port is another computer's; an app claims none
|
|
998
1173
|
continue
|
|
999
1174
|
try:
|
|
1000
1175
|
other_port = port_of(str(other.get("origin", "")))
|
|
@@ -1096,6 +1271,8 @@ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
|
|
|
1096
1271
|
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
1097
1272
|
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
|
|
1098
1273
|
ROUTES = ("subscription", "key")
|
|
1274
|
+
#: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
|
|
1275
|
+
USAGE_ROUTES = ROUTES + ("platform-metered",)
|
|
1099
1276
|
|
|
1100
1277
|
|
|
1101
1278
|
def configuration_problems(doc: Any) -> List[str]:
|
|
@@ -1289,12 +1466,12 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
|
|
|
1289
1466
|
basis = cost_basis or ("unknown" if cost_usd is None else "provider")
|
|
1290
1467
|
if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
|
|
1291
1468
|
raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
|
|
1292
|
-
if route is not None and route not in
|
|
1293
|
-
raise ServiceError("route is subscription or
|
|
1469
|
+
if route is not None and route not in USAGE_ROUTES:
|
|
1470
|
+
raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
|
|
1294
1471
|
receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
|
|
1295
1472
|
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
1296
1473
|
if route is not None:
|
|
1297
|
-
receipt["route"] = route # the operator's own login in its vendor's runner,
|
|
1474
|
+
receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
|
|
1298
1475
|
return receipt
|
|
1299
1476
|
|
|
1300
1477
|
|
|
@@ -2346,18 +2523,30 @@ def remote_token_file(service_id: str, instance: str = "default", directory: Opt
|
|
|
2346
2523
|
|
|
2347
2524
|
def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
|
|
2348
2525
|
summary: Optional[str] = None, doctor: Optional[List[str]] = None,
|
|
2349
|
-
directory: Optional[Path] = None, installed_by:
|
|
2526
|
+
directory: Optional[Path] = None, installed_by: Any = None,
|
|
2527
|
+
runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
|
|
2350
2528
|
"""DEC-0019, installer side: register an online service on THIS computer — the token file
|
|
2351
|
-
(0600, never printed) and the descriptor. The same token is set on the platform as a secret.
|
|
2529
|
+
(0600, never printed) and the descriptor. The same token is set on the platform as a secret.
|
|
2530
|
+
|
|
2531
|
+
DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
|
|
2532
|
+
register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
|
|
2533
|
+
string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
|
|
2534
|
+
credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
|
|
2352
2535
|
if not token or len(token.strip()) < 16:
|
|
2353
2536
|
raise ServiceError("the service token must be at least 16 characters.")
|
|
2354
2537
|
root = directory or services_dir()
|
|
2355
2538
|
token_file = remote_token_file(service_id, instance, root)
|
|
2356
2539
|
descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
|
|
2357
2540
|
"placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
|
|
2358
|
-
"lifecycle": {"manager": "none"}, "installedAt": now_iso(),
|
|
2541
|
+
"lifecycle": {"manager": "none"}, "installedAt": now_iso(),
|
|
2542
|
+
"installedBy": installed_by if installed_by is not None else
|
|
2543
|
+
{"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
|
|
2359
2544
|
if summary:
|
|
2360
2545
|
descriptor["summary"] = summary
|
|
2546
|
+
if runner is not None:
|
|
2547
|
+
descriptor["runner"] = dict(runner)
|
|
2548
|
+
if peer is not None:
|
|
2549
|
+
descriptor["peer"] = dict(peer)
|
|
2361
2550
|
if doctor:
|
|
2362
2551
|
descriptor["commands"] = {"doctor": list(doctor)}
|
|
2363
2552
|
problems = validate_descriptor(descriptor)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -350,7 +350,8 @@ def register(args: argparse.Namespace) -> int:
|
|
|
350
350
|
else {"manager": "none"},
|
|
351
351
|
"paths": {"data": str(data), "logs": []},
|
|
352
352
|
"fabricManifest": str(manifest),
|
|
353
|
-
|
|
353
|
+
# DEC-0061: who installed it, by role and never by name — a person runs this register command.
|
|
354
|
+
"installedAt": fs.now_iso(), "installedBy": {"by": "person", "role": "operator", "tool": "sample_service.py register %s" % VERSION},
|
|
354
355
|
}
|
|
355
356
|
try:
|
|
356
357
|
path = fs.write_descriptor(descriptor, Path(args.services_dir) if args.services_dir else None)
|
|
@@ -5,7 +5,7 @@ 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.24.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
|
|
11
11
|
---
|
|
@@ -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.24.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
|