@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.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 +34 -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 +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +103 -6
- 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_env.py +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.44.0 (2026-10-22)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **What an agent uses, `fabric-connections/0.1`** (FAA-62, contract DEC-0096, FAC-SEM-127; asked by Fabric Dashboards
|
|
8
|
+
on board thread `131648c0`). A new kit module in both twins, `fabric_connections` / `fabric-connections.mjs`:
|
|
9
|
+
`connections_extension(declared, degraded, states)` / `connectionsExtension(declared, {degraded, states})` builds
|
|
10
|
+
the `extensions` entry, each connection's `state` from the service's own checks, else `degraded` when `degraded[]`
|
|
11
|
+
names it, else `unknown`, and refuses a declaration a host would hide (the schema, an id twice, a credential).
|
|
12
|
+
`connections_view` / `connectionsView` read a peer's list as the contract's reference does; both answer all 127
|
|
13
|
+
cases of the contract's `fixtures/vectors/connections.json`, vendored by `make_connections_vectors.mjs`.
|
|
14
|
+
`build_well_known` / `buildWellKnown` refuse an invalid declaration passed in by hand, `check_service.py` reports
|
|
15
|
+
`well-known.connections`, and the `fabric_service` facades re-export the four names. The Python twin reads
|
|
16
|
+
credentials with the reference's ECMAScript semantics (ASCII case folding and word boundary, ECMAScript's whitespace,
|
|
17
|
+
a URL WHATWG refuses read as text only, its query and fragment read as WHATWG serializes them, a name's leading
|
|
18
|
+
`?` dropped as `URLSearchParams` drops it, an unpaired surrogate read as U+FFFD); 1,073 more inputs, answered by the
|
|
19
|
+
contract's reader into `connections-extra-vectors.json`, hold both twins to it. Inputs whose answer changes with the
|
|
20
|
+
Node release (the reference parses URLs with the runtime's own `new URL`) are left out of that set. Where Python cannot tell whether WHATWG refuses a URL (a
|
|
21
|
+
non-ASCII host), it reads the URL's credentials anyway, so a doubt hides a declaration rather than publish one
|
|
22
|
+
(FAA-98 ports UTS46 to close that gap).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **`check_dashboard_layout.mjs` exits 3 when no rule was measured** (FAA-96): every rule `NOT_RUN` (no Playwright, a
|
|
27
|
+
browser that does not start, a service it cannot open) or no rule at all. It exited 0, and 2 for an empty report
|
|
28
|
+
since 0.42.1. 0 still needs at least one rule measured and none failing.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **The session worker waits for an exit status to its deadline after a closed pipe** (FAA-97): a `fabric-mcp` that
|
|
33
|
+
closed its stdin and exited 3 more than a second later was logged as `outcome=unknown` and its spool kept.
|
|
34
|
+
- **OpenClaw `--remove` drops our load path when its folder is gone** (FAA-90): the ownership check read a manifest
|
|
35
|
+
that no longer existed, so the dead path stayed.
|
|
36
|
+
|
|
3
37
|
## 0.43.0 (2026-10-22)
|
|
4
38
|
|
|
5
39
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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.44.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.44.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
|
|
11
11
|
---
|
|
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
14
14
|
compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
|
|
15
15
|
metadata:
|
|
16
16
|
author: PassionCode.ai
|
|
17
|
-
version: "0.
|
|
17
|
+
version: "0.44.0"
|
|
18
18
|
contract-version: "0.1.0"
|
|
19
19
|
extension: "fabric-service/0.1"
|
|
20
20
|
extension-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
|
|
@@ -248,6 +248,11 @@ and declare it with `extensions=fs.dashboard_extension()`. Read
|
|
|
248
248
|
declaration and how to upgrade a dashboard built before the kit: an upgrade you may make yourself,
|
|
249
249
|
which the plugin's session-start notice offers in a repository that owns the service.
|
|
250
250
|
|
|
251
|
+
**Say what the agent uses** (`fabric-connections/0.1`, DEC-0096) so Fabric Dashboards draws its chain: the other
|
|
252
|
+
agents, models, connectors, stores and channels, each with its state, from
|
|
253
|
+
`connections_extension()` / `connectionsExtension()` in `fabric_connections`. Never a credential in it, never a call
|
|
254
|
+
count (that is the usage report); the details are in [the wire reference](references/protocol.md).
|
|
255
|
+
|
|
251
256
|
The events feed is a **view over the log you already keep** (a jobs table, a JSONL
|
|
252
257
|
journal), not a second store. Each event is one sentence a person reads; set
|
|
253
258
|
`notify: true` only when the operator must decide, or something failed or degrades their
|
|
@@ -284,7 +289,7 @@ propagation, event trace pairs, and `board.responder` for a service that declare
|
|
|
284
289
|
any `FAIL`. A `NOT_RUN` is not a pass;
|
|
285
290
|
name it in the report. A service with a dashboard also runs the layout probe,
|
|
286
291
|
`node <skill-dir>/scripts/check_dashboard_layout.mjs --service <id>` (WebKit at 360, 480, 768
|
|
287
|
-
and 1280 px; needs Playwright, else `NOT_RUN`).
|
|
292
|
+
and 1280 px; needs Playwright, else `NOT_RUN` and exit 3: nothing was measured).
|
|
288
293
|
|
|
289
294
|
Then prove the lock: start a second copy by hand against the same data directory; it
|
|
290
295
|
must exit 75 while the first keeps serving. Prove recovery: `kill -9` the launchd pid;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
CHANGED
|
@@ -137,7 +137,8 @@ the floor minus 40 and at 360, 480, 768 and 1280 px, and reports ED-1…ED-5 per
|
|
|
137
137
|
Playwright where it runs (`npm i -D playwright && npx playwright install webkit`, or `--playwright
|
|
138
138
|
<dir>`); without it every rule is `NOT_RUN`, and the probe asks for no login code. ED-3 also fails a link clipped
|
|
139
139
|
vertically by the viewport or a clipping ancestor; ED-4 is `NOT_RUN` on a page past its 6000-element cap. It does not measure edge shades or that a refresh keeps
|
|
140
|
-
the scroll position: say so in the report. A `NOT_RUN` is not a pass.
|
|
140
|
+
the scroll position: say so in the report. A `NOT_RUN` is not a pass. Exit codes: 0 when no rule fails and at least
|
|
141
|
+
one passed, 1 on a `FAIL`, 2 on a usage error, 3 when no rule was measured (every rule `NOT_RUN`, since 0.44.0).
|
|
141
142
|
|
|
142
143
|
## Upgrading an existing dashboard
|
|
143
144
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -63,6 +63,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
63
63
|
| L3 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
|
|
64
64
|
| L3 | `fabric_paths` | the services directory, a service's folders, atomic and private writes |
|
|
65
65
|
| L3 | `fabric_protocols` | protocol revisions (DEC-0067) and feed data (DEC-0070) |
|
|
66
|
+
| L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
|
|
66
67
|
| L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
|
|
67
68
|
| L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
|
|
68
69
|
| L3 | `fabric_backups` | settings backups (DEC-0025) |
|
|
@@ -93,6 +93,26 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
93
93
|
ED-9): `minWidth` 360…1280 (required), `appearance` ⊆ `scheme`, `theme`, `themes`, `kit`
|
|
94
94
|
(`name@semver`), nothing else. The kits build it with `dashboard_extension()` /
|
|
95
95
|
`dashboardExtension()`; see [the dashboard kit reference](dashboard-kit.md).
|
|
96
|
+
- `extensions` MAY say what the service uses under
|
|
97
|
+
`https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1` (`fabric-connections/0.1`, DEC-0096):
|
|
98
|
+
`{connections: [...]}`, at most 64, each `{id, kind, target, state}` with `kind` one of `service`, `model`,
|
|
99
|
+
`connector`, `store`, `channel`, `state` one of `ok`, `degraded`, `down`, `unknown`, and optionally `capability`
|
|
100
|
+
(a `service` only), `label`, `purpose`, `reason`, `lastAt`. No member carries a credential, and each `id` is named
|
|
101
|
+
once: a host hides a list that breaks either rule (FAC-SEM-127). A `degraded[].source` equal to an `id` is that
|
|
102
|
+
connection failing. The kits build it with `connections_extension(declared, degraded, states)` /
|
|
103
|
+
`connectionsExtension(declared, {degraded, states})`: `state` comes from the service's own checks (`states`, by
|
|
104
|
+
`id`), else `degraded` when `degraded[]` names it, else `unknown`; a declaration a host would hide is refused with
|
|
105
|
+
`ServiceError`, and `build_well_known` refuses one passed in by hand. `connections_view` / `connectionsView` read
|
|
106
|
+
a peer's list as Fabric Dashboards does, and `check_service.py` reports `well-known.connections`. Build it each
|
|
107
|
+
time the well-known document is built, from the same `degraded` list:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
ext = fs.dashboard_extension()
|
|
111
|
+
ext.update(connections_extension(
|
|
112
|
+
[{"id": "router", "kind": "model", "target": "openai/gpt-5.1", "purpose": "drafts replies"},
|
|
113
|
+
{"id": "notify", "kind": "service", "target": "communicator.default", "capability": "communicator.notify"}],
|
|
114
|
+
degraded=degraded, states={"router": "ok" if model_answered else "down"}))
|
|
115
|
+
```
|
|
96
116
|
- `surfaces.mcp.auth`: `descriptor` (default), `own` (DEC-0024) or `delegation` (DEC-0039, DL-16):
|
|
97
117
|
the MCP surface takes only the estate's delegation tokens, and the top-level
|
|
98
118
|
`auth.delegation {issuer, jwks}` is then required. With `own` or `delegation` the descriptor's
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// scroll box) and ED-5 (sticky chrome within min(120 px, a quarter of the height), anchors landing
|
|
14
14
|
// below it, no nested full-height scroller). What it cannot measure is NOT_RUN with the reason,
|
|
15
15
|
// never a pass. WebKit is the default engine (Safari's). Exit 0 when nothing FAILs, 1 when
|
|
16
|
-
// something does, 2 on a usage error
|
|
16
|
+
// something does, 2 on a usage error, 3 when no rule was measured (every rule NOT_RUN). It reads; it never clicks a
|
|
17
|
+
// control of the page.
|
|
17
18
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
18
19
|
import { createRequire } from 'node:module';
|
|
19
20
|
import { homedir, platform } from 'node:os';
|
|
@@ -261,11 +262,13 @@ export function report(rows, opts, target) {
|
|
|
261
262
|
for (const r of rows) process.stdout.write(`${r.verdict.padEnd(8)} ${(r.rule + (r.width ? '@' + r.width : '')).padEnd(10)} ${r.evidence}\n`);
|
|
262
263
|
process.stdout.write(`\n${rows.length} rule(s), ${failed} FAIL, ${rows.filter((r) => r.verdict === 'NOT_RUN').length} NOT_RUN\n`);
|
|
263
264
|
}
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
if (failed) return 1;
|
|
266
|
+
// Nothing measured is never a pass (FAA-96): no rule, or every rule NOT_RUN, exits 3.
|
|
267
|
+
if (!rows.some((r) => r.verdict === 'PASS')) {
|
|
268
|
+
process.stderr.write('no rule was measured, so nothing passed\n');
|
|
269
|
+
return 3;
|
|
267
270
|
}
|
|
268
|
-
return
|
|
271
|
+
return 0;
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
async function main(argv) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -52,6 +52,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
52
52
|
|
|
53
53
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
54
54
|
import fabric_service as fs # noqa: E402
|
|
55
|
+
import fabric_connections as fcn # noqa: E402
|
|
55
56
|
import fabric_interop as fi # noqa: E402
|
|
56
57
|
|
|
57
58
|
Result = Dict[str, str]
|
|
@@ -238,6 +239,14 @@ class Probe:
|
|
|
238
239
|
findings = fs.protocols_findings(wk["protocols"])
|
|
239
240
|
self.add("well-known.protocols", "FAIL" if findings else "PASS",
|
|
240
241
|
"; ".join("%s %s" % (f["instancePath"], f["message"]) for f in findings) or ", ".join(wk["protocols"]))
|
|
242
|
+
extensions = wk.get("extensions") if isinstance(wk.get("extensions"), dict) else {}
|
|
243
|
+
if fcn.CONNECTIONS_EXTENSION_KEY in extensions:
|
|
244
|
+
# DEC-0096, FAC-SEM-127: what it uses, valid, each id once, no credential; a host hides an invalid list.
|
|
245
|
+
view = fcn.connections_view(wk)
|
|
246
|
+
failing = sum(1 for c in view["connections"] if c["failing"])
|
|
247
|
+
self.add("well-known.connections", "FAIL" if view["reported"] else "PASS",
|
|
248
|
+
"; ".join(f["message"] for f in fcn.connections_rules(wk)[:5]) or
|
|
249
|
+
"%d connection(s), %d named in degraded[]" % (len(view["connections"]), failing))
|
|
241
250
|
self.configuration_rule()
|
|
242
251
|
svc = wk.get("service") or {}
|
|
243
252
|
answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// What an agent uses: fabric-connections/0.1, the extension keyed extensions/connections/0.1 (DEC-0096, FAC-SEM-127).
|
|
2
|
+
// connectionsView reads a well-known document as the contract's connectionsView does (it never throws);
|
|
3
|
+
// connectionsExtension builds the `extensions` entry a service declares, `state` from its own checks and its degraded[].
|
|
4
|
+
// Part of the Node service kit (FAA-62, layer L3); the twin of fabric_connections.py. Node 20+, no dependency.
|
|
5
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
6
|
+
import { BEARER_INSIDE, JWT_SHAPE, longSecret, secretUrl } from './fabric-secrets.mjs';
|
|
7
|
+
import { isObject } from './fabric-jsvalue.mjs';
|
|
8
|
+
|
|
9
|
+
export const CONNECTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1';
|
|
10
|
+
export const KINDS = Object.freeze(['service', 'model', 'connector', 'store', 'channel']);
|
|
11
|
+
export const STATES = Object.freeze(['ok', 'degraded', 'down', 'unknown']);
|
|
12
|
+
export const MAX_CONNECTIONS = 64;
|
|
13
|
+
const MEMBERS = ['id', 'kind', 'target', 'capability', 'state', 'label', 'purpose', 'reason', 'lastAt'];
|
|
14
|
+
const STRING_MEMBERS = ['id', 'target', 'capability', 'label', 'purpose', 'reason'];
|
|
15
|
+
|
|
16
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
17
|
+
const TARGET = /^[^\s]+$/u;
|
|
18
|
+
const SERVICE_TARGET = /^[a-z0-9][a-z0-9-]{0,63}(\.[a-z0-9][a-z0-9-]{0,63})?$/u;
|
|
19
|
+
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/u;
|
|
20
|
+
const LINE = /^[^\n\r]+$/u;
|
|
21
|
+
|
|
22
|
+
// ajv-formats `date-time` in its full mode (the contract's validator): a date, `t` or a space, a time with a zone.
|
|
23
|
+
const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
|
|
24
|
+
const TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
|
|
25
|
+
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
26
|
+
function fullDate(value) {
|
|
27
|
+
const m = DATE.exec(value);
|
|
28
|
+
if (!m) return false;
|
|
29
|
+
const [year, month, day] = [+m[1], +m[2], +m[3]];
|
|
30
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
31
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && leap ? 29 : DAYS[month]);
|
|
32
|
+
}
|
|
33
|
+
function fullTime(value) {
|
|
34
|
+
const m = TIME.exec(value);
|
|
35
|
+
if (!m) return false;
|
|
36
|
+
const [hour, minute, second] = [+m[1], +m[2], +m[3]];
|
|
37
|
+
const sign = m[5] === '-' ? -1 : 1;
|
|
38
|
+
const [zoneH, zoneM] = [+(m[6] || 0), +(m[7] || 0)];
|
|
39
|
+
if (zoneH > 23 || zoneM > 59 || !m[4]) return false;
|
|
40
|
+
if (hour <= 23 && minute <= 59 && second < 60) return true;
|
|
41
|
+
const utcMin = minute - zoneM * sign; // a leap second, at 23:59 UTC only
|
|
42
|
+
const utcHour = hour - zoneH * sign - (utcMin < 0 ? 1 : 0);
|
|
43
|
+
return (utcHour === 23 || utcHour === -1) && (utcMin === 59 || utcMin === -1) && second < 61;
|
|
44
|
+
}
|
|
45
|
+
/** The `date-time` format as the contract's validator (ajv-formats, full mode) reads it. */
|
|
46
|
+
export function dateTime(value) {
|
|
47
|
+
const parts = value.split(/t|\s/i);
|
|
48
|
+
return parts.length === 2 && fullDate(parts[0]) && fullTime(parts[1]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const length = (value) => [...value].length; // code points, as ajv counts them
|
|
52
|
+
|
|
53
|
+
/** service-connections-extension.schema.json, read as the contract's validator reads it. */
|
|
54
|
+
function shapeProblems(value) {
|
|
55
|
+
if (!isObject(value)) return ['/ type: must be object'];
|
|
56
|
+
// A member's name is never echoed: a secret-shaped name would reach the error text and a probe's evidence.
|
|
57
|
+
const problems = Object.keys(value).filter((key) => key !== 'connections').map(() => '/ additionalProperties: a member other than connections');
|
|
58
|
+
if (!Object.hasOwn(value, 'connections')) return [...problems, '/ required: connections'];
|
|
59
|
+
const connections = value.connections;
|
|
60
|
+
if (!Array.isArray(connections)) return [...problems, '/connections type: must be array'];
|
|
61
|
+
if (connections.length > MAX_CONNECTIONS) problems.push(`/connections maxItems: at most ${MAX_CONNECTIONS}`);
|
|
62
|
+
connections.forEach((item, index) => {
|
|
63
|
+
const at = `/connections/${index}`;
|
|
64
|
+
if (!isObject(item)) { problems.push(`${at} type: must be object`); return; }
|
|
65
|
+
for (const key of ['id', 'kind', 'target', 'state']) if (!Object.hasOwn(item, key)) problems.push(`${at} required: ${key}`);
|
|
66
|
+
for (const key of Object.keys(item)) if (!MEMBERS.includes(key)) problems.push(`${at} additionalProperties: a member the schema does not name`);
|
|
67
|
+
for (const key of [...STRING_MEMBERS, 'lastAt']) if (Object.hasOwn(item, key) && typeof item[key] !== 'string') problems.push(`${at}/${key} type: must be string`);
|
|
68
|
+
const text = (key) => (Object.hasOwn(item, key) && typeof item[key] === 'string' ? item[key] : undefined);
|
|
69
|
+
if (text('id') !== undefined && !ID.test(text('id'))) problems.push(`${at}/id pattern`);
|
|
70
|
+
if (Object.hasOwn(item, 'kind') && !KINDS.includes(item.kind)) problems.push(`${at}/kind enum`);
|
|
71
|
+
if (Object.hasOwn(item, 'state') && !STATES.includes(item.state)) problems.push(`${at}/state enum`);
|
|
72
|
+
const target = text('target');
|
|
73
|
+
if (target !== undefined && !(length(target) >= 1 && length(target) <= 200 && TARGET.test(target))) problems.push(`${at}/target length or pattern`);
|
|
74
|
+
if (text('capability') !== undefined && !CAPABILITY.test(text('capability'))) problems.push(`${at}/capability pattern`);
|
|
75
|
+
for (const key of ['label', 'purpose', 'reason']) {
|
|
76
|
+
const line = text(key);
|
|
77
|
+
if (line !== undefined && !(length(line) >= 1 && length(line) <= 300 && LINE.test(line))) problems.push(`${at}/${key} length or pattern`);
|
|
78
|
+
}
|
|
79
|
+
if (text('lastAt') !== undefined && !dateTime(text('lastAt'))) problems.push(`${at}/lastAt format: date-time`);
|
|
80
|
+
if (Object.hasOwn(item, 'kind')) {
|
|
81
|
+
if (item.kind !== 'service' && Object.hasOwn(item, 'capability')) problems.push(`${at}/capability: only a service connection names a capability`);
|
|
82
|
+
if (item.kind === 'service' && target !== undefined && !SERVICE_TARGET.test(target)) problems.push(`${at}/target pattern: a service target is an id.instance key or a product id`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return problems;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The contract's KEY_SHAPE (src/connections.ts): a provider key where it starts a word, case-sensitive prefixes, the
|
|
89
|
+
// `sk-` rules and a Telegram bot token.
|
|
90
|
+
const KEY_SHAPE = /(?:^|[^A-Za-z0-9])(?:sk-(?=[A-Za-z0-9_-]*\d)(?:(?=[A-Za-z0-9_-]*[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}|(?:bot)?\d{6,12}:(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{34,35}(?![A-Za-z0-9_-]))/;
|
|
91
|
+
const SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
|
|
92
|
+
const CLOSING = /[)\]}>"'.,;:!?]/;
|
|
93
|
+
const TRIED_BACK = 1;
|
|
94
|
+
/** The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
|
|
95
|
+
* quotes and punctuation that end a sentence, and with TRIED_BACK of them put back (`]` closes an IPv6 host). */
|
|
96
|
+
function urlsIn(value) {
|
|
97
|
+
const urls = [];
|
|
98
|
+
for (const piece of value.split(/\s+/)) {
|
|
99
|
+
for (const match of piece.matchAll(SCHEME)) {
|
|
100
|
+
const url = piece.slice(match.index);
|
|
101
|
+
let end = url.length;
|
|
102
|
+
while (end > 0 && CLOSING.test(url[end - 1])) end -= 1;
|
|
103
|
+
for (let back = 0; back <= TRIED_BACK && end + back <= url.length; back += 1) urls.push(url.slice(0, end + back));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return urls;
|
|
107
|
+
}
|
|
108
|
+
/** A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's `://`. */
|
|
109
|
+
const longRun = (value) =>
|
|
110
|
+
value.split(/\s+/).some((piece) => {
|
|
111
|
+
const at = piece.search(SCHEME);
|
|
112
|
+
const cut = at < 0 ? -1 : piece.indexOf('://', at);
|
|
113
|
+
return longSecret(cut < 0 ? piece : piece.slice(0, cut));
|
|
114
|
+
});
|
|
115
|
+
const credential = (value, runs) =>
|
|
116
|
+
JWT_SHAPE.test(value) || KEY_SHAPE.test(value) || BEARER_INSIDE.test(value) || (runs && longRun(value)) || urlsIn(value).some(secretUrl);
|
|
117
|
+
|
|
118
|
+
/** What the schema cannot state: an id named twice, a credential in any string member. */
|
|
119
|
+
function beyondShape(list) {
|
|
120
|
+
const errors = [];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
list.forEach((connection, index) => {
|
|
123
|
+
if (seen.has(connection.id)) errors.push(`/connections/${index}/id duplicate: it is named twice`);
|
|
124
|
+
seen.add(connection.id);
|
|
125
|
+
for (const member of STRING_MEMBERS) {
|
|
126
|
+
const value = connection[member];
|
|
127
|
+
const runs = member !== 'id' && member !== 'capability' && !(member === 'target' && connection.kind === 'model');
|
|
128
|
+
if (typeof value === 'string' && credential(value, runs)) errors.push(`/connections/${index}/${member} credential: a connection never carries a credential`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const none = () => ({ declared: false, reported: false, connections: [], errors: [] });
|
|
135
|
+
|
|
136
|
+
/** A host's reading of a well-known document's connections (the contract's connectionsView): never throws. */
|
|
137
|
+
export function connectionsView(wellKnown) {
|
|
138
|
+
const extensions = isObject(wellKnown) && isObject(wellKnown.extensions) ? wellKnown.extensions : undefined;
|
|
139
|
+
if (extensions === undefined || !Object.hasOwn(extensions, CONNECTIONS_EXTENSION_KEY)) return none();
|
|
140
|
+
const value = extensions[CONNECTIONS_EXTENSION_KEY];
|
|
141
|
+
let errors = shapeProblems(value);
|
|
142
|
+
if (!errors.length) errors = beyondShape(value.connections);
|
|
143
|
+
if (errors.length) return { ...none(), reported: true, errors };
|
|
144
|
+
const degraded = Array.isArray(wellKnown.degraded) ? wellKnown.degraded : [];
|
|
145
|
+
const failing = new Set(degraded.filter(isObject).map((entry) => entry.source).filter((source) => typeof source === 'string'));
|
|
146
|
+
return { declared: true, reported: false, connections: value.connections.map((c) => ({ ...c, failing: failing.has(c.id) })), errors: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const pointerToken = (token) => token.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
150
|
+
|
|
151
|
+
/** FAC-SEM-127: the connections a well-known document declares are valid, each id once, no credential. */
|
|
152
|
+
export function connectionsRules(wellKnown) {
|
|
153
|
+
const view = connectionsView(wellKnown);
|
|
154
|
+
const at = `/extensions/${pointerToken(CONNECTIONS_EXTENSION_KEY)}`;
|
|
155
|
+
return view.errors.map((error) => ({ code: 'FAC-SEM-127', instancePath: at, message: `the connections declaration is invalid (${error}); a host treats it as not declared and reports it (DEC-0096)` }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `extensions` entry a service declares (DEC-0096): what it uses, each connection's `state` from the service's
|
|
160
|
+
* own checks (`states`, by id), else `degraded` when a degraded[].source names it, else `unknown`; a declared `state`
|
|
161
|
+
* is kept. Throws ServiceError on a declaration a host would hide: a service never publishes one that reads as
|
|
162
|
+
* "declared nothing".
|
|
163
|
+
*/
|
|
164
|
+
export function connectionsExtension(declared, options = {}) {
|
|
165
|
+
const { degraded: givenDegraded, states: givenStates } = options ?? {};
|
|
166
|
+
const degraded = givenDegraded ?? [];
|
|
167
|
+
const states = givenStates ?? {};
|
|
168
|
+
if (!Array.isArray(declared) || !Array.isArray(degraded) || !isObject(states)) {
|
|
169
|
+
throw new ServiceError('Connections declaration: declared and degraded are lists, states an object by id.');
|
|
170
|
+
}
|
|
171
|
+
const failing = new Set(degraded.filter(isObject).map((entry) => entry.source).filter((source) => typeof source === 'string'));
|
|
172
|
+
const connections = declared.map((item) => {
|
|
173
|
+
if (!isObject(item)) return item; // the shape check names it and the declaration is refused
|
|
174
|
+
const connection = { ...item };
|
|
175
|
+
if (!Object.hasOwn(connection, 'state')) {
|
|
176
|
+
const id = typeof connection.id === 'string' ? connection.id : undefined;
|
|
177
|
+
// An invalid check value is refused by the shape check, never read as a missing one.
|
|
178
|
+
connection.state = id !== undefined && Object.hasOwn(states, id) ? states[id] : (failing.has(id) ? 'degraded' : 'unknown');
|
|
179
|
+
}
|
|
180
|
+
return connection;
|
|
181
|
+
});
|
|
182
|
+
const value = { connections };
|
|
183
|
+
const view = connectionsView({ extensions: { [CONNECTIONS_EXTENSION_KEY]: value } });
|
|
184
|
+
if (view.reported) throw new ServiceError(`Connections declaration: ${view.errors.join('; ')}.`);
|
|
185
|
+
return { [CONNECTIONS_EXTENSION_KEY]: value };
|
|
186
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric
|
|
|
6
6
|
import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
7
7
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
8
8
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
9
|
+
import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
|
|
9
10
|
import { ROUTES } from './fabric-usage.mjs';
|
|
10
11
|
|
|
11
12
|
// The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
@@ -369,6 +370,11 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
369
370
|
const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
|
|
370
371
|
if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
|
|
371
372
|
}
|
|
373
|
+
if (Object.hasOwn(extensions, CONNECTIONS_EXTENSION_KEY)) {
|
|
374
|
+
// DEC-0096: a host hides an invalid declaration (FAC-SEM-127); a service never publishes one.
|
|
375
|
+
const view = connectionsView({ extensions: { [CONNECTIONS_EXTENSION_KEY]: extensions[CONNECTIONS_EXTENSION_KEY] } });
|
|
376
|
+
if (view.reported) throw new ServiceError(`Connections declaration: ${view.errors.join('; ')}.`);
|
|
377
|
+
}
|
|
372
378
|
doc.extensions = { ...extensions };
|
|
373
379
|
}
|
|
374
380
|
if (configuration !== undefined) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -41,12 +41,12 @@ export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]
|
|
|
41
41
|
/** A value that says it is a credential, or a provider key's shape. */
|
|
42
42
|
export const SECRET_VALUE = /(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})/i;
|
|
43
43
|
const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
44
|
-
const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
|
|
44
|
+
export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
|
|
45
45
|
|
|
46
46
|
/** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
|
|
47
47
|
export const secretValue = (value) => JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || longSecret(value);
|
|
48
48
|
|
|
49
|
-
const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
|
|
49
|
+
export const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
|
|
50
50
|
const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
|
|
51
51
|
const pathLike = (piece) =>
|
|
52
52
|
PATH_LIKE.test(piece) ||
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
feedDataFindings,
|
|
41
41
|
feedDataProblems,
|
|
42
42
|
} from './fabric-protocols.mjs';
|
|
43
|
+
export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
|
|
43
44
|
export {
|
|
44
45
|
EXIT_ALREADY_RUNNING,
|
|
45
46
|
LOCK_WAIT_SUPERVISED_SECONDS,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""What an agent uses: `fabric-connections/0.1`, the extension keyed extensions/connections/0.1 (DEC-0096, FAC-SEM-127).
|
|
2
|
+
`connections_view` reads a well-known document as the contract's `connectionsView` does (it never raises);
|
|
3
|
+
`connections_extension` builds the `extensions` entry a service declares, `state` from its own checks and its
|
|
4
|
+
`degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ipaddress
|
|
8
|
+
import re
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import sys as _sys # noqa: E402
|
|
13
|
+
from pathlib import Path as _Path # noqa: E402
|
|
14
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
15
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
16
|
+
|
|
17
|
+
from fabric_base import ServiceError # noqa: E402
|
|
18
|
+
from fabric_secrets import CREDENTIAL_PAIRS, CREDENTIAL_WORDS, JWT_SHAPE, _long_secret, raw_values # noqa: E402
|
|
19
|
+
|
|
20
|
+
CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
|
|
21
|
+
KINDS = ("service", "model", "connector", "store", "channel")
|
|
22
|
+
STATES = ("ok", "degraded", "down", "unknown")
|
|
23
|
+
MAX_CONNECTIONS = 64
|
|
24
|
+
_MEMBERS = ("id", "kind", "target", "capability", "state", "label", "purpose", "reason", "lastAt")
|
|
25
|
+
_STRING_MEMBERS = ("id", "target", "capability", "label", "purpose", "reason")
|
|
26
|
+
|
|
27
|
+
# ECMAScript's \s, which the schema's patterns and the contract's splits use: Python's \s differs (it takes \x1c-\x1f
|
|
28
|
+
# and \x85, and leaves U+FEFF out).
|
|
29
|
+
_JS_SPACE = "\t\n\x0b\x0c\r -
"
|
|
30
|
+
_SPACES = re.compile("[%s]+" % _JS_SPACE)
|
|
31
|
+
_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
|
|
32
|
+
_TARGET = re.compile("[^%s]+" % _JS_SPACE)
|
|
33
|
+
_SERVICE_TARGET = re.compile(r"[a-z0-9][a-z0-9-]{0,63}(?:\.[a-z0-9][a-z0-9-]{0,63})?")
|
|
34
|
+
_CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
|
|
35
|
+
_LINE = re.compile("[^\n\r]+")
|
|
36
|
+
|
|
37
|
+
# ajv-formats `date-time` in its full mode (the contract's validator): a date, `t` or a space, a time with a zone.
|
|
38
|
+
_DT_SEPARATOR = re.compile("[tT%s]" % _JS_SPACE)
|
|
39
|
+
_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})")
|
|
40
|
+
_TIME = re.compile(r"([0-9]{2}):([0-9]{2}):([0-9]{2}(?:\.[0-9]+)?)(z|([+-])([0-9]{2})(?::?([0-9]{2}))?)?", re.IGNORECASE | re.ASCII)
|
|
41
|
+
_DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
42
|
+
|
|
43
|
+
# The contract's KEY_SHAPE (src/connections.ts): a provider key where it starts a word, case-sensitive prefixes, the
|
|
44
|
+
# `sk-` rules and a Telegram bot token.
|
|
45
|
+
_KEY_SHAPE = re.compile(
|
|
46
|
+
r"(?:^|[^A-Za-z0-9])(?:sk-(?=[A-Za-z0-9_-]*[0-9])(?:(?=[A-Za-z0-9_-]*[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
|
|
47
|
+
r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
|
|
48
|
+
r"|(?:bot)?[0-9]{6,12}:(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{34,35}(?![A-Za-z0-9_-]))")
|
|
49
|
+
_URL_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE | re.ASCII)
|
|
50
|
+
_CLOSING = ")]}>\"'.,;:!?"
|
|
51
|
+
_TRIED_BACK = 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _date(value: str) -> bool:
|
|
55
|
+
match = _DATE.fullmatch(value)
|
|
56
|
+
if not match:
|
|
57
|
+
return False
|
|
58
|
+
year, month, day = (int(g) for g in match.groups())
|
|
59
|
+
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
60
|
+
return 1 <= month <= 12 and 1 <= day <= (29 if month == 2 and leap else _DAYS[month])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _time(value: str) -> bool:
|
|
64
|
+
match = _TIME.fullmatch(value)
|
|
65
|
+
if not match:
|
|
66
|
+
return False
|
|
67
|
+
hour, minute, second = int(match.group(1)), int(match.group(2)), float(match.group(3))
|
|
68
|
+
zone, sign = match.group(4), -1 if match.group(5) == "-" else 1
|
|
69
|
+
zone_h, zone_m = int(match.group(6) or 0), int(match.group(7) or 0)
|
|
70
|
+
if zone_h > 23 or zone_m > 59 or not zone:
|
|
71
|
+
return False
|
|
72
|
+
if hour <= 23 and minute <= 59 and second < 60:
|
|
73
|
+
return True
|
|
74
|
+
utc_min = minute - zone_m * sign # a leap second, at 23:59 UTC only
|
|
75
|
+
utc_hour = hour - zone_h * sign - (1 if utc_min < 0 else 0)
|
|
76
|
+
return utc_hour in (23, -1) and utc_min in (59, -1) and second < 61
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def date_time(value: str) -> bool:
|
|
80
|
+
"""The `date-time` format as the contract's validator (ajv-formats, full mode) reads it."""
|
|
81
|
+
parts = _DT_SEPARATOR.split(value)
|
|
82
|
+
return len(parts) == 2 and _date(parts[0]) and _time(parts[1])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _length(value: str) -> int:
|
|
86
|
+
return len(value) # code points, as ajv counts them
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _shape_problems(value: Any) -> List[str]:
|
|
90
|
+
"""service-connections-extension.schema.json, read as the contract's validator reads it."""
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
return ["/ type: must be object"]
|
|
93
|
+
# A member's name is never echoed: a secret-shaped name would reach the error text and a probe's evidence.
|
|
94
|
+
problems = ["/ additionalProperties: a member other than connections" for key in value if key != "connections"]
|
|
95
|
+
if "connections" not in value:
|
|
96
|
+
return problems + ["/ required: connections"]
|
|
97
|
+
connections = value["connections"]
|
|
98
|
+
if not isinstance(connections, list):
|
|
99
|
+
return problems + ["/connections type: must be array"]
|
|
100
|
+
if len(connections) > MAX_CONNECTIONS:
|
|
101
|
+
problems.append("/connections maxItems: at most %d" % MAX_CONNECTIONS)
|
|
102
|
+
for index, item in enumerate(connections):
|
|
103
|
+
at = "/connections/%d" % index
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
problems.append("%s type: must be object" % at)
|
|
106
|
+
continue
|
|
107
|
+
problems += ["%s required: %s" % (at, key) for key in ("id", "kind", "target", "state") if key not in item]
|
|
108
|
+
problems += ["%s additionalProperties: a member the schema does not name" % at for key in item if key not in _MEMBERS]
|
|
109
|
+
for key in _STRING_MEMBERS + ("lastAt",):
|
|
110
|
+
if key in item and not isinstance(item[key], str):
|
|
111
|
+
problems.append("%s/%s type: must be string" % (at, key))
|
|
112
|
+
text = {k: v for k, v in item.items() if isinstance(v, str)}
|
|
113
|
+
if "id" in text and not _ID.fullmatch(text["id"]):
|
|
114
|
+
problems.append("%s/id pattern" % at)
|
|
115
|
+
if "kind" in item and item["kind"] not in KINDS:
|
|
116
|
+
problems.append("%s/kind enum" % at)
|
|
117
|
+
if "state" in item and item["state"] not in STATES:
|
|
118
|
+
problems.append("%s/state enum" % at)
|
|
119
|
+
if "target" in text and not (1 <= _length(text["target"]) <= 200 and _TARGET.fullmatch(text["target"])):
|
|
120
|
+
problems.append("%s/target length or pattern" % at)
|
|
121
|
+
if "capability" in text and not _CAPABILITY.fullmatch(text["capability"]):
|
|
122
|
+
problems.append("%s/capability pattern" % at)
|
|
123
|
+
for key in ("label", "purpose", "reason"):
|
|
124
|
+
if key in text and not (1 <= _length(text[key]) <= 300 and _LINE.fullmatch(text[key])):
|
|
125
|
+
problems.append("%s/%s length or pattern" % (at, key))
|
|
126
|
+
if "lastAt" in text and not date_time(text["lastAt"]):
|
|
127
|
+
problems.append("%s/lastAt format: date-time" % at)
|
|
128
|
+
if "kind" in item:
|
|
129
|
+
if item["kind"] != "service" and "capability" in item:
|
|
130
|
+
problems.append("%s/capability: only a service connection names a capability" % at)
|
|
131
|
+
if item["kind"] == "service" and "target" in text and not _SERVICE_TARGET.fullmatch(text["target"]):
|
|
132
|
+
problems.append("%s/target pattern: a service target is an id.instance key or a product id" % at)
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _urls_in(value: str) -> List[str]:
|
|
137
|
+
"""The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
|
|
138
|
+
quotes and punctuation that end a sentence, and with _TRIED_BACK of them put back (`]` closes an IPv6 host)."""
|
|
139
|
+
urls: List[str] = []
|
|
140
|
+
for piece in _SPACES.split(value):
|
|
141
|
+
for match in _URL_SCHEME.finditer(piece):
|
|
142
|
+
url = piece[match.start():]
|
|
143
|
+
end = len(url)
|
|
144
|
+
while end > 0 and url[end - 1] in _CLOSING:
|
|
145
|
+
end -= 1
|
|
146
|
+
back = 0
|
|
147
|
+
while back <= _TRIED_BACK and end + back <= len(url):
|
|
148
|
+
urls.append(url[:end + back])
|
|
149
|
+
back += 1
|
|
150
|
+
return urls
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _long_run(value: str) -> bool:
|
|
154
|
+
"""A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's
|
|
155
|
+
`://` (the URL itself is read by secret_url)."""
|
|
156
|
+
for piece in _SPACES.split(value):
|
|
157
|
+
found = _URL_SCHEME.search(piece)
|
|
158
|
+
cut = piece.find("://", found.start()) if found else -1
|
|
159
|
+
if _long_secret(piece if cut < 0 else piece[:cut]):
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# The shared detector (DEC-0044) as the contract's ECMAScript reads it: ASCII case folding, ECMAScript's \s and `.`,
|
|
165
|
+
# an ASCII word boundary. The kit's fabric_secrets keeps Python's semantics for its other callers (LC-21); FAC-SEM-127
|
|
166
|
+
# is held to the reference, so this module reads with the reference's (blind reading of 0.44.0, finding 1).
|
|
167
|
+
_NOT_LINE_END = "[^\n\r\u2028\u2029]"
|
|
168
|
+
_SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
|
|
169
|
+
r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (_JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
|
|
170
|
+
_BEARER = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % _JS_SPACE,
|
|
171
|
+
re.IGNORECASE | re.ASCII)
|
|
172
|
+
_PATH_LIKE = re.compile(r"(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
|
|
173
|
+
_ARGUMENT_SPLIT = re.compile("[%s=]" % _JS_SPACE)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _credential_name(name: str) -> bool:
|
|
177
|
+
"""fabric_secrets.credential_name with ECMAScript's whitespace in the word split (U+FEFF splits a name there)."""
|
|
178
|
+
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
179
|
+
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
|
|
180
|
+
split = [w.lower() for w in re.split("[%s._-]+" % _JS_SPACE, spaced) if w]
|
|
181
|
+
words = split + [w[:-1] for w in split if len(w) > 2 and w.endswith("s")]
|
|
182
|
+
if any(w in CREDENTIAL_WORDS for w in words) or (len(split) == 1 and any(w in CREDENTIAL_PAIRS for w in words)):
|
|
183
|
+
return True
|
|
184
|
+
return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _secret_value(value: str) -> bool:
|
|
188
|
+
return bool(JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _long_secret(value))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _path_like(piece: str) -> bool:
|
|
192
|
+
return bool(_PATH_LIKE.match(piece)) or (
|
|
193
|
+
bool(re.search(r"[\\/]", piece))
|
|
194
|
+
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}\Z", piece))
|
|
195
|
+
or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", segment) for segment in re.split(r"[\\/]", piece))))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _secret_argument(value: str) -> bool:
|
|
199
|
+
if JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _BEARER.search(value):
|
|
200
|
+
return True
|
|
201
|
+
return any(any(_long_secret(part) for part in re.split(r"[\\/]", piece)) if _path_like(piece) else _long_secret(piece)
|
|
202
|
+
for piece in _ARGUMENT_SPLIT.split(value))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
|
|
206
|
+
_FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
207
|
+
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ipv4_number(part: str) -> Optional[int]:
|
|
211
|
+
if part == "":
|
|
212
|
+
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
213
|
+
base = 10
|
|
214
|
+
if part[:2].lower() == "0x":
|
|
215
|
+
part, base = part[2:], 16
|
|
216
|
+
elif len(part) > 1 and part[0] == "0":
|
|
217
|
+
part, base = part[1:], 8
|
|
218
|
+
if part == "":
|
|
219
|
+
return 0
|
|
220
|
+
digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
|
|
221
|
+
return int(part, base) if all(c in digits for c in part) else None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _domain_ok(host: str) -> bool:
|
|
225
|
+
"""The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
|
|
226
|
+
if host.startswith("["):
|
|
227
|
+
return host.endswith("]") and _ipv6_ok(host[1:-1])
|
|
228
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
|
|
229
|
+
try:
|
|
230
|
+
host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
|
|
231
|
+
except UnicodeDecodeError:
|
|
232
|
+
return False
|
|
233
|
+
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
234
|
+
return False
|
|
235
|
+
# A non-ASCII host goes through UTS46 in WHATWG; this module does not port it. When unsure, the host is taken as
|
|
236
|
+
# valid, so its URL's credentials are read: the doubt then hides a list rather than publish one (round-2 reading).
|
|
237
|
+
if any(ord(c) > 0x7f for c in host):
|
|
238
|
+
return True
|
|
239
|
+
labels = host.split(".")
|
|
240
|
+
if labels and labels[-1] == "" and len(labels) > 1:
|
|
241
|
+
labels = labels[:-1]
|
|
242
|
+
last = labels[-1] if labels else ""
|
|
243
|
+
if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
|
|
244
|
+
return host != ""
|
|
245
|
+
numbers = [_ipv4_number(label) for label in labels]
|
|
246
|
+
if len(numbers) > 4 or any(n is None for n in numbers):
|
|
247
|
+
return False
|
|
248
|
+
return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _ipv6_ok(text: str) -> bool:
|
|
252
|
+
if "%" in text:
|
|
253
|
+
return False # a zone id: `ipaddress` takes one, WHATWG does not
|
|
254
|
+
try:
|
|
255
|
+
ipaddress.IPv6Address(text)
|
|
256
|
+
return True
|
|
257
|
+
except ValueError:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
262
|
+
_QUERY_ENCODE = set(chr(c) for c in range(0x21)) | set('"#<>')
|
|
263
|
+
_FRAGMENT_ENCODE = set(chr(c) for c in range(0x21)) | set('"<>`')
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _serialize(text: str, encode: set) -> str:
|
|
267
|
+
"""A query or fragment as WHATWG serializes it (`url.search`, `url.hash`): the set's ASCII and every code point
|
|
268
|
+
above U+007E as UTF-8 percent-escapes in upper-case hex; `%` stays as written (round-3 reading of 0.44.0)."""
|
|
269
|
+
out = []
|
|
270
|
+
for c in text:
|
|
271
|
+
if c in encode or ord(c) > 0x7e:
|
|
272
|
+
out.append("".join("%%%02X" % b for b in c.encode("utf-8")))
|
|
273
|
+
else:
|
|
274
|
+
out.append(c)
|
|
275
|
+
return "".join(out)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
|
|
279
|
+
"""The password, query and fragment `new URL(value)` gives, or None where it throws. A subset of the WHATWG URL
|
|
280
|
+
parser: an absolute URL with `//` authority, as the members that reach it are (_urls_in starts each at a scheme)."""
|
|
281
|
+
# A JavaScript string becomes a USV string first: an unpaired surrogate is U+FFFD (json.loads keeps one).
|
|
282
|
+
value = _SURROGATE.sub("\ufffd", value)
|
|
283
|
+
value = re.sub(r"[\t\n\r]", "", value.strip("".join(chr(c) for c in range(0x21))))
|
|
284
|
+
match = re.match(r"([A-Za-z][A-Za-z0-9+.-]*):", value)
|
|
285
|
+
if not match:
|
|
286
|
+
return None
|
|
287
|
+
scheme, rest = match.group(1).lower(), value[match.end():]
|
|
288
|
+
special = scheme in _SPECIAL
|
|
289
|
+
if special:
|
|
290
|
+
cut = min([i for i in (rest.find("?"), rest.find("#")) if i >= 0] or [len(rest)])
|
|
291
|
+
rest = rest[:cut].replace("\\", "/") + rest[cut:] # a backslash is a slash before the query only
|
|
292
|
+
if not rest.startswith("//"):
|
|
293
|
+
return {"password": "", "query": "", "fragment": ""} if not special else None
|
|
294
|
+
rest = rest[2:].lstrip("/") if special and scheme != "file" else rest[2:]
|
|
295
|
+
end = len(rest)
|
|
296
|
+
for stop in "/?#":
|
|
297
|
+
at = rest.find(stop)
|
|
298
|
+
if at >= 0:
|
|
299
|
+
end = min(end, at)
|
|
300
|
+
authority, tail = rest[:end], rest[end:]
|
|
301
|
+
fragment = _serialize(tail.split("#", 1)[1], _FRAGMENT_ENCODE) if "#" in tail else ""
|
|
302
|
+
before = tail.split("#", 1)[0]
|
|
303
|
+
query = _serialize(before.split("?", 1)[1], _QUERY_ENCODE | ({"'"} if special else set())) if "?" in before else ""
|
|
304
|
+
if scheme == "file":
|
|
305
|
+
# A drive letter (`C:`, `c|`) is an empty host and the start of the path.
|
|
306
|
+
if authority == "" or re.fullmatch(r"[A-Za-z][:|]", authority) or _domain_ok(authority):
|
|
307
|
+
return {"password": "", "query": query, "fragment": fragment}
|
|
308
|
+
return None
|
|
309
|
+
userinfo, _, hostport = authority.rpartition("@")
|
|
310
|
+
password = userinfo.split(":", 1)[1] if ":" in userinfo else ""
|
|
311
|
+
if hostport.startswith("["):
|
|
312
|
+
close = hostport.find("]")
|
|
313
|
+
if close < 0:
|
|
314
|
+
return None
|
|
315
|
+
host, after = hostport[:close + 1], hostport[close + 1:]
|
|
316
|
+
if after and not after.startswith(":"):
|
|
317
|
+
return None
|
|
318
|
+
port = after[1:]
|
|
319
|
+
else:
|
|
320
|
+
host, _, port = hostport.partition(":")
|
|
321
|
+
if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
|
|
322
|
+
return None
|
|
323
|
+
if special:
|
|
324
|
+
if host == "" or not _domain_ok(host):
|
|
325
|
+
return None
|
|
326
|
+
elif host.startswith("["):
|
|
327
|
+
if not _ipv6_ok(host[1:-1]):
|
|
328
|
+
return None
|
|
329
|
+
elif any(c in _FORBIDDEN_HOST for c in host):
|
|
330
|
+
return None
|
|
331
|
+
elif host == "" and ("@" in authority or port):
|
|
332
|
+
return None
|
|
333
|
+
return {"password": password, "query": query, "fragment": fragment}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _raw_values(query: str) -> Dict[str, List[str]]:
|
|
337
|
+
"""fabric_secrets.raw_values with the name read as URLSearchParams reads it: one leading `?` of the raw piece is
|
|
338
|
+
dropped before decoding (`??token` names `?token`, `%3Ftoken` names `?token`; round-3 reading)."""
|
|
339
|
+
values: Dict[str, List[str]] = {}
|
|
340
|
+
for piece in query.split("&"):
|
|
341
|
+
if piece == "":
|
|
342
|
+
continue
|
|
343
|
+
at = piece.find("=")
|
|
344
|
+
raw_name = piece if at < 0 else piece[:at]
|
|
345
|
+
merged = raw_values(raw_name[1:] if raw_name.startswith("?") else raw_name)
|
|
346
|
+
name = next(iter(merged), "")
|
|
347
|
+
values.setdefault(name, []).extend(raw_values("x" + piece[at:])["x"] if at >= 0 else [""])
|
|
348
|
+
return values
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _secret_url(value: str) -> bool:
|
|
352
|
+
"""The contract's secretUrl (DEC-0044): what _secret_argument finds, a password, or a query or fragment value
|
|
353
|
+
named or shaped like a credential, in a URL WHATWG parses; one it refuses is read as an argument only."""
|
|
354
|
+
if _secret_argument(value):
|
|
355
|
+
return True
|
|
356
|
+
parts = _whatwg_parts(value)
|
|
357
|
+
if parts is None:
|
|
358
|
+
return False
|
|
359
|
+
if parts["password"]:
|
|
360
|
+
return True
|
|
361
|
+
return any((raw != "" and _credential_name(name)) or _secret_value(raw)
|
|
362
|
+
for part in (parts["query"], parts["fragment"]) for name, raws in _raw_values(part).items() for raw in raws)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _credential(value: str, runs: bool) -> bool:
|
|
366
|
+
return bool(JWT_SHAPE.search(value) or _KEY_SHAPE.search(value) or _BEARER.search(value)
|
|
367
|
+
or (runs and _long_run(value)) or any(_secret_url(url) for url in _urls_in(value)))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _beyond_shape(connections: Sequence[Dict[str, Any]]) -> List[str]:
|
|
371
|
+
"""What the schema cannot state: an id named twice, a credential in any string member."""
|
|
372
|
+
problems: List[str] = []
|
|
373
|
+
seen = set()
|
|
374
|
+
for index, connection in enumerate(connections):
|
|
375
|
+
if connection["id"] in seen:
|
|
376
|
+
problems.append("/connections/%d/id duplicate: it is named twice" % index)
|
|
377
|
+
seen.add(connection["id"])
|
|
378
|
+
for member in _STRING_MEMBERS:
|
|
379
|
+
value = connection.get(member)
|
|
380
|
+
runs = member not in ("id", "capability") and not (member == "target" and connection.get("kind") == "model")
|
|
381
|
+
if isinstance(value, str) and _credential(value, runs):
|
|
382
|
+
problems.append("/connections/%d/%s credential: a connection never carries a credential" % (index, member))
|
|
383
|
+
return problems
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def connections_view(well_known: Any) -> Dict[str, Any]:
|
|
387
|
+
"""A host's reading of a well-known document's connections (the contract's connectionsView): never raises. Without
|
|
388
|
+
the key nothing is declared; an invalid value (the schema, a repeated id, a credential) is not declared and is
|
|
389
|
+
`reported`; a valid list comes back with `failing` set on each connection a `degraded[].source` names."""
|
|
390
|
+
none: Dict[str, Any] = {"declared": False, "reported": False, "connections": [], "errors": []}
|
|
391
|
+
extensions = well_known.get("extensions") if isinstance(well_known, dict) else None
|
|
392
|
+
if not isinstance(extensions, dict) or CONNECTIONS_EXTENSION_KEY not in extensions:
|
|
393
|
+
return none
|
|
394
|
+
value = extensions[CONNECTIONS_EXTENSION_KEY]
|
|
395
|
+
errors = _shape_problems(value)
|
|
396
|
+
if not errors:
|
|
397
|
+
errors = _beyond_shape(value["connections"])
|
|
398
|
+
if errors:
|
|
399
|
+
return dict(none, reported=True, errors=errors)
|
|
400
|
+
degraded = well_known.get("degraded")
|
|
401
|
+
failing = {entry["source"] for entry in (degraded if isinstance(degraded, list) else [])
|
|
402
|
+
if isinstance(entry, dict) and isinstance(entry.get("source"), str)}
|
|
403
|
+
return {"declared": True, "reported": False, "errors": [],
|
|
404
|
+
"connections": [dict(c, failing=c["id"] in failing) for c in value["connections"]]}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def connections_rules(well_known: Any) -> List[Dict[str, str]]:
|
|
408
|
+
"""FAC-SEM-127: the connections a well-known document declares are valid, each id once, no credential."""
|
|
409
|
+
view = connections_view(well_known)
|
|
410
|
+
at = "/extensions/" + CONNECTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
411
|
+
return [{"code": "FAC-SEM-127", "instancePath": at,
|
|
412
|
+
"message": "the connections declaration is invalid (%s); a host treats it as not declared and reports it "
|
|
413
|
+
"(DEC-0096)" % error} for error in view["errors"]]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def connections_extension(declared: Iterable[Mapping[str, Any]], degraded: Sequence[Mapping[str, Any]] = (),
|
|
417
|
+
states: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
|
|
418
|
+
"""The `extensions` entry a service declares (DEC-0096): what it uses, each connection's `state` from the service's
|
|
419
|
+
own checks (`states`, by id), else `degraded` when a `degraded[].source` names it, else `unknown`. A declared
|
|
420
|
+
`state` is kept. Raises ServiceError on a declaration a host would hide (the schema, a repeated id, a credential):
|
|
421
|
+
a service never publishes one that reads as "declared nothing"."""
|
|
422
|
+
if declared is None or isinstance(declared, (str, bytes, Mapping)) or not isinstance(declared, Iterable):
|
|
423
|
+
raise ServiceError("Connections declaration: declared is a list of connections.")
|
|
424
|
+
if degraded is None:
|
|
425
|
+
degraded = ()
|
|
426
|
+
if states is None:
|
|
427
|
+
states = {}
|
|
428
|
+
if isinstance(degraded, (str, bytes, Mapping)) or not isinstance(degraded, Iterable) or not isinstance(states, Mapping):
|
|
429
|
+
raise ServiceError("Connections declaration: degraded is a list of entries and states a mapping by id.")
|
|
430
|
+
failing = {entry.get("source") for entry in degraded if isinstance(entry, Mapping) and isinstance(entry.get("source"), str)}
|
|
431
|
+
connections: List[Any] = []
|
|
432
|
+
for item in declared:
|
|
433
|
+
if not isinstance(item, Mapping):
|
|
434
|
+
connections.append(item) # the shape check names it and the declaration is refused
|
|
435
|
+
continue
|
|
436
|
+
connection = dict(item)
|
|
437
|
+
if "state" not in connection:
|
|
438
|
+
ident = connection.get("id") if isinstance(connection.get("id"), str) else None
|
|
439
|
+
if ident is not None and ident in states:
|
|
440
|
+
connection["state"] = states[ident] # an invalid check value is refused by the shape check
|
|
441
|
+
else:
|
|
442
|
+
connection["state"] = "degraded" if ident in failing else "unknown"
|
|
443
|
+
connections.append(connection)
|
|
444
|
+
value = {"connections": connections}
|
|
445
|
+
view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: value}})
|
|
446
|
+
if view["reported"]:
|
|
447
|
+
raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
|
|
448
|
+
return {CONNECTIONS_EXTENSION_KEY: value}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -35,6 +35,7 @@ from fabric_paths import (
|
|
|
35
35
|
from fabric_protocols import (
|
|
36
36
|
protocols_findings,
|
|
37
37
|
)
|
|
38
|
+
from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
|
|
38
39
|
from fabric_usage import (
|
|
39
40
|
ROUTES,
|
|
40
41
|
)
|
|
@@ -570,6 +571,11 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
570
571
|
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
571
572
|
if problems:
|
|
572
573
|
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
574
|
+
if CONNECTIONS_EXTENSION_KEY in extensions:
|
|
575
|
+
# DEC-0096: a host hides an invalid declaration (FAC-SEM-127); a service never publishes one.
|
|
576
|
+
view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
|
|
577
|
+
if view["reported"]:
|
|
578
|
+
raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
|
|
573
579
|
doc["extensions"] = dict(extensions)
|
|
574
580
|
if configuration is not None:
|
|
575
581
|
problems = configuration_problems(configuration)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -179,6 +179,12 @@ from fabric_backups import ( # noqa: F401
|
|
|
179
179
|
newest_intact_backup,
|
|
180
180
|
restore_plan,
|
|
181
181
|
)
|
|
182
|
+
from fabric_connections import ( # noqa: F401
|
|
183
|
+
CONNECTIONS_EXTENSION_KEY,
|
|
184
|
+
connections_extension,
|
|
185
|
+
connections_rules,
|
|
186
|
+
connections_view,
|
|
187
|
+
)
|
|
182
188
|
from fabric_descriptor import ( # noqa: F401
|
|
183
189
|
DASHBOARD_EXTENSION_KEY,
|
|
184
190
|
DASHBOARD_KIT,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fabric-service-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"about": "The service kit's modules in dependency order: a module imports only modules listed before it. Written by fabric_kit.py build; edit the order and layers by hand, never the digests.",
|
|
5
5
|
"python": [
|
|
6
6
|
{
|
|
@@ -141,6 +141,16 @@
|
|
|
141
141
|
],
|
|
142
142
|
"sha256": "71482284e57334b2d364a44596fc4183baddfe61a80083d3099b7ef423dc6f38"
|
|
143
143
|
},
|
|
144
|
+
{
|
|
145
|
+
"module": "fabric_connections",
|
|
146
|
+
"file": "fabric_connections.py",
|
|
147
|
+
"layer": "L3",
|
|
148
|
+
"imports": [
|
|
149
|
+
"fabric_base",
|
|
150
|
+
"fabric_secrets"
|
|
151
|
+
],
|
|
152
|
+
"sha256": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11"
|
|
153
|
+
},
|
|
144
154
|
{
|
|
145
155
|
"module": "fabric_lock",
|
|
146
156
|
"file": "fabric_lock.py",
|
|
@@ -182,12 +192,13 @@
|
|
|
182
192
|
"layer": "L3",
|
|
183
193
|
"imports": [
|
|
184
194
|
"fabric_base",
|
|
195
|
+
"fabric_connections",
|
|
185
196
|
"fabric_paths",
|
|
186
197
|
"fabric_protocols",
|
|
187
198
|
"fabric_secrets",
|
|
188
199
|
"fabric_usage"
|
|
189
200
|
],
|
|
190
|
-
"sha256": "
|
|
201
|
+
"sha256": "5f6c8f3593fe0cef92d866109cd9def807f67dd5beae2c4bca05452f50521080"
|
|
191
202
|
},
|
|
192
203
|
{
|
|
193
204
|
"module": "fabric_auth",
|
|
@@ -242,6 +253,7 @@
|
|
|
242
253
|
"fabric_auth",
|
|
243
254
|
"fabric_backups",
|
|
244
255
|
"fabric_base",
|
|
256
|
+
"fabric_connections",
|
|
245
257
|
"fabric_descriptor",
|
|
246
258
|
"fabric_events",
|
|
247
259
|
"fabric_jsvalue",
|
|
@@ -254,7 +266,7 @@
|
|
|
254
266
|
"fabric_time",
|
|
255
267
|
"fabric_usage"
|
|
256
268
|
],
|
|
257
|
-
"sha256": "
|
|
269
|
+
"sha256": "d411433ce6ff3e81189b10b2c1a8462335a97cd99574ab0ee7d1c28cf48e1694"
|
|
258
270
|
},
|
|
259
271
|
{
|
|
260
272
|
"module": "fabric_profiles",
|
|
@@ -393,7 +405,7 @@
|
|
|
393
405
|
"file": "fabric-secrets.mjs",
|
|
394
406
|
"layer": "L0",
|
|
395
407
|
"imports": [],
|
|
396
|
-
"sha256": "
|
|
408
|
+
"sha256": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766"
|
|
397
409
|
},
|
|
398
410
|
{
|
|
399
411
|
"module": "fabric-crypto",
|
|
@@ -501,6 +513,17 @@
|
|
|
501
513
|
],
|
|
502
514
|
"sha256": "0ecc3c19bb0bd0393ff3a9ec503034e0ab251eaaf0254edf353e15d1b561f761"
|
|
503
515
|
},
|
|
516
|
+
{
|
|
517
|
+
"module": "fabric-connections",
|
|
518
|
+
"file": "fabric-connections.mjs",
|
|
519
|
+
"layer": "L3",
|
|
520
|
+
"imports": [
|
|
521
|
+
"fabric-base",
|
|
522
|
+
"fabric-jsvalue",
|
|
523
|
+
"fabric-secrets"
|
|
524
|
+
],
|
|
525
|
+
"sha256": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc"
|
|
526
|
+
},
|
|
504
527
|
{
|
|
505
528
|
"module": "fabric-lock",
|
|
506
529
|
"file": "fabric-lock.mjs",
|
|
@@ -542,12 +565,13 @@
|
|
|
542
565
|
"layer": "L3",
|
|
543
566
|
"imports": [
|
|
544
567
|
"fabric-base",
|
|
568
|
+
"fabric-connections",
|
|
545
569
|
"fabric-paths",
|
|
546
570
|
"fabric-protocols",
|
|
547
571
|
"fabric-secrets",
|
|
548
572
|
"fabric-usage"
|
|
549
573
|
],
|
|
550
|
-
"sha256": "
|
|
574
|
+
"sha256": "e75b16bbf15ee72a3c581df8164533ea84270f6167e7a0d2527e15bf4b78457d"
|
|
551
575
|
},
|
|
552
576
|
{
|
|
553
577
|
"module": "fabric-auth",
|
|
@@ -603,6 +627,7 @@
|
|
|
603
627
|
"fabric-auth",
|
|
604
628
|
"fabric-backups",
|
|
605
629
|
"fabric-base",
|
|
630
|
+
"fabric-connections",
|
|
606
631
|
"fabric-descriptor",
|
|
607
632
|
"fabric-events",
|
|
608
633
|
"fabric-lock",
|
|
@@ -613,7 +638,7 @@
|
|
|
613
638
|
"fabric-supervisors",
|
|
614
639
|
"fabric-usage"
|
|
615
640
|
],
|
|
616
|
-
"sha256": "
|
|
641
|
+
"sha256": "bc67550e52aa6aa9c6d2ed6cbae52418a3e836623e17164166699f953d8aafa8"
|
|
617
642
|
},
|
|
618
643
|
{
|
|
619
644
|
"module": "fabric-profiles",
|
|
@@ -1822,6 +1847,78 @@
|
|
|
1822
1847
|
"fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
|
|
1823
1848
|
"fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
|
|
1824
1849
|
"share-registration.schema.json": "0df979d5973809d194723b23a0d18e520d9f1badc0f4a9e557aa33cbe7bc2922"
|
|
1850
|
+
},
|
|
1851
|
+
"0.44.0": {
|
|
1852
|
+
"fabric_jsvalue.py": "0cd4eab0a5d6ae35d097f7c59a08dc29173cb61c561c49e5291a8dad15d36779",
|
|
1853
|
+
"fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
|
|
1854
|
+
"fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
|
|
1855
|
+
"fabric_secrets.py": "68a0b5e9e90c468ec9ac67b2f0fd1848e08813d6ec1ec14a2a47337eb1a98911",
|
|
1856
|
+
"fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
|
|
1857
|
+
"fabric_estate.py": "50cc12166a00b680ba5b2e458200912d7bcfbef70bd73ec4edf57c7fcfa9b687",
|
|
1858
|
+
"fabric_delegation.py": "350e30cc0fb527bce1739c5f96bb9bec9028144def9d1b2630d1478529f8f996",
|
|
1859
|
+
"fabric_share.py": "78506fe1a60829c525dbf6266a0aa0749a2164290ed94da8249a20b4e9be5605",
|
|
1860
|
+
"fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
|
|
1861
|
+
"fabric_package.py": "09e5b4d20564ccb318f5a5e4f33df0359873fa8239cf4f5fcd21c8af481fd211",
|
|
1862
|
+
"fabric_base.py": "684533b3a2aa3acd9b9e645a7a443427d279408f9f12f13456a0a991d57954ce",
|
|
1863
|
+
"fabric_platform.py": "8b0cd89193bc55da204a4a1850665c96929c6778523a8715b8156c5643745288",
|
|
1864
|
+
"fabric_paths.py": "0d87e572bde2b930391b27aa370d808e093ce45cfba6148706f5318219906d30",
|
|
1865
|
+
"fabric_protocols.py": "71482284e57334b2d364a44596fc4183baddfe61a80083d3099b7ef423dc6f38",
|
|
1866
|
+
"fabric_connections.py": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11",
|
|
1867
|
+
"fabric_lock.py": "ba2399d2137c7a7791b7133b9db9cca36693f4eb7e30955b25a75ade814e95ba",
|
|
1868
|
+
"fabric_usage.py": "ce685cb9fdfd62ea75fff8b1b2be12a2ba758da783e4bfb4dcebe47bcbeb832b",
|
|
1869
|
+
"fabric_backups.py": "02dc5eb66ec956723d3ddba07b6dd15f6a9726d901f4d60edfd02eee57880ceb",
|
|
1870
|
+
"fabric_descriptor.py": "5f6c8f3593fe0cef92d866109cd9def807f67dd5beae2c4bca05452f50521080",
|
|
1871
|
+
"fabric_auth.py": "55794587750f6c9f40935b74295d83d7096cb4b26a0b36ac292334cd87d8f853",
|
|
1872
|
+
"fabric_events.py": "0086886c2c29dbd5c9d152be9d375c5bdf4a29f89b8095baff8490c2bcd64e47",
|
|
1873
|
+
"fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
|
|
1874
|
+
"fabric_supervisors.py": "a2dbf15aa1c4eb9de42996b413103fa4814afd2e4e66d1ab7315f30e06ab6841",
|
|
1875
|
+
"fabric_service.py": "d411433ce6ff3e81189b10b2c1a8462335a97cd99574ab0ee7d1c28cf48e1694",
|
|
1876
|
+
"fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
|
|
1877
|
+
"fabric_interop.py": "67a78026ad434bb638c8230b6bfadc9cbe67561842ae7493c77d912855f38d61",
|
|
1878
|
+
"fabric_hub.py": "b9ac852e601b1d54cf61351677a628afc69faeed7d1fc74ebe541c1cf38e0c2a",
|
|
1879
|
+
"fabric_board_shapes.py": "a8daefdc9283219206875b5d964b49552b6b3ba7e69c925abe08a44afc91c234",
|
|
1880
|
+
"fabric_board_link.py": "b5bff994cb615c6a4bb0d88d020e3a59504166180e1fa1aea3be93224f2aeccb",
|
|
1881
|
+
"fabric_board_session.py": "878ceed970a84087a320fd8eb971c210613a0fa2c524b68793fb32042910581e",
|
|
1882
|
+
"fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
|
|
1883
|
+
"fabric_board.py": "0f075894b3e352194fcaf3fc7d08dee4e9f1c215e859769ed85fa5010e4a1599",
|
|
1884
|
+
"fabric_http.py": "5d7f72af3a73fdbe8c1f487ecf60cef23a7c46289eba542b03aff4706b8218d4",
|
|
1885
|
+
"fabric_openrouter.py": "2017d898c17001d7275f58267de653be7ecf6acd17ad8d9b24ecb7a43e5617d4",
|
|
1886
|
+
"fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
|
|
1887
|
+
"fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
|
|
1888
|
+
"fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
|
|
1889
|
+
"fabric-secrets.mjs": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766",
|
|
1890
|
+
"fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
|
|
1891
|
+
"fabric-estate.mjs": "912b1ceab57ed20f946986bf14ae2faf41fb8ea539e818164496874f3749ef65",
|
|
1892
|
+
"fabric-delegation.mjs": "2bc4310c6c7f0f6a11ba29d8bad2341feb0e0285758c33080e879e0a3a5cf128",
|
|
1893
|
+
"fabric-share.mjs": "d40a7405d7bd4c3213ed691330b7549afe8e023db0753b582e249714e672010d",
|
|
1894
|
+
"fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
|
|
1895
|
+
"fabric-package.mjs": "34590076b2c6939b2892e86ed24b62cc5973315ec7a322de19c553336b049bf2",
|
|
1896
|
+
"fabric-base.mjs": "17a92f5e9b71a855648e39e06388bad7f1b969605bd7b1f534ddf67b41a2fb5d",
|
|
1897
|
+
"fabric-platform.mjs": "1349e3638c21caf7da7937ef6a5dfeefe05a8ee733520f0d581624095efeb388",
|
|
1898
|
+
"fabric-paths.mjs": "e6f5e2ce7c8e7a1452d6ee69977489f4c09ca6f1d3100df9c303274f0b7b16b1",
|
|
1899
|
+
"fabric-protocols.mjs": "0ecc3c19bb0bd0393ff3a9ec503034e0ab251eaaf0254edf353e15d1b561f761",
|
|
1900
|
+
"fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
|
|
1901
|
+
"fabric-lock.mjs": "ac219ab0a326d5c43c7c4c255e0c3843d4794de795a60e3af661e7d5350ea052",
|
|
1902
|
+
"fabric-usage.mjs": "e413569620bbc8221bc59e24b27e22d1483825ed18457801ed58d54a57de8114",
|
|
1903
|
+
"fabric-backups.mjs": "849acdc6eba0fff648c8158e61f6b68cccd7bedf0880f7d5ec03a6a20f1e7005",
|
|
1904
|
+
"fabric-descriptor.mjs": "e75b16bbf15ee72a3c581df8164533ea84270f6167e7a0d2527e15bf4b78457d",
|
|
1905
|
+
"fabric-auth.mjs": "3ea6796470fcd829bc72a772fb7e4c37532a09d1e37ff41592dcf1a1a3074ca2",
|
|
1906
|
+
"fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
|
|
1907
|
+
"fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
|
|
1908
|
+
"fabric-supervisors.mjs": "9d61c1a17538a5e2453e33e5ae347337336748041bc054478a96dd51f3d4fc51",
|
|
1909
|
+
"fabric-service.mjs": "bc67550e52aa6aa9c6d2ed6cbae52418a3e836623e17164166699f953d8aafa8",
|
|
1910
|
+
"fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
|
|
1911
|
+
"fabric-interop.mjs": "81c0b09045ada54f6645f40d935c0de06119793631b3240b1a613f4f2afd9d84",
|
|
1912
|
+
"fabric-hub.mjs": "c005115a864cfd67daa4862097bb14acfe5a60bf653897e5bf8a570dd80f1070",
|
|
1913
|
+
"fabric-board-shapes.mjs": "ad1094397540862b104d9317d6902031d35e64b16c12bf849fb883e12cb78d45",
|
|
1914
|
+
"fabric-board-link.mjs": "4cd6d4e21713a38c8e56f7d59db3148638b8c27c08e774d96a0f6283c6049e0a",
|
|
1915
|
+
"fabric-board-session.mjs": "744a8c9a518c27f2ddfd20b1b22afebc3ccc4f59aeeb7a38d7c12c2ddcae158d",
|
|
1916
|
+
"fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
|
|
1917
|
+
"fabric-board.mjs": "9e7bfe4d412521982224ef12bebfc2ff255eeeaf8d96cf40234ac18de68e7a59",
|
|
1918
|
+
"fabric-http.mjs": "f886bcb49c4353d039387ac9568738dda26697a94dd07fc6a6204500f0bc27bf",
|
|
1919
|
+
"fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
|
|
1920
|
+
"fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
|
|
1921
|
+
"share-registration.schema.json": "0df979d5973809d194723b23a0d18e520d9f1badc0f4a9e557aa33cbe7bc2922"
|
|
1825
1922
|
}
|
|
1826
1923
|
},
|
|
1827
1924
|
"data": {
|
|
@@ -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.44.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
|
|
11
11
|
---
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py
CHANGED
|
@@ -191,11 +191,12 @@ class McpStdio:
|
|
|
191
191
|
try:
|
|
192
192
|
self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
|
193
193
|
self.proc.stdin.flush()
|
|
194
|
-
except OSError: # a server that
|
|
194
|
+
except OSError: # a server that closed its stdin: say why it exits, never a broken pipe
|
|
195
|
+
# Its exit status may come later than the pipe's end (FAA-97): wait for it up to the deadline.
|
|
195
196
|
try:
|
|
196
|
-
self.proc.wait(timeout=max(0.0,
|
|
197
|
+
self.proc.wait(timeout=max(0.0, self.deadline - time.monotonic()))
|
|
197
198
|
except subprocess.TimeoutExpired:
|
|
198
|
-
|
|
199
|
+
raise TimeoutError("fabric-mcp closed its stdin and did not exit") from None
|
|
199
200
|
self._ended()
|
|
200
201
|
|
|
201
202
|
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
@@ -59,8 +59,10 @@ def openclaw_edit(data, script, remove):
|
|
|
59
59
|
return True
|
|
60
60
|
if not value.replace('\\', '/').endswith('/working-with-fabric/scripts/hosts/openclaw'):
|
|
61
61
|
return False
|
|
62
|
+
if remove and not Path(value).expanduser().exists():
|
|
63
|
+
return True # our layout, its folder gone (FAA-90): no manifest left to read, and a dead load path
|
|
62
64
|
try:
|
|
63
|
-
manifest = fs.read_small(Path(value) / 'openclaw.plugin.json')
|
|
65
|
+
manifest = fs.read_small(Path(value).expanduser() / 'openclaw.plugin.json')
|
|
64
66
|
return isinstance(manifest, str) and json.loads(manifest).get('id') == IDENTITY
|
|
65
67
|
except (ValueError, AttributeError):
|
|
66
68
|
return False
|