@passioncode-ai/fabric-agent-adapter 0.8.0 → 0.8.2
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 +71 -0
- package/COMMERCIAL-LICENSE.md +1 -1
- package/README.md +4 -4
- 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 +26 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +27 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +37 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +35 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +262 -23
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +28 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 - 2026-10-10
|
|
4
|
+
|
|
5
|
+
The Python service kit runs on Windows and Linux (contract DEC-0032, DEC-0033), and the contract pin moves to the DEC-0033 merge.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Default contract pin → `78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5`** (DEC-0032, DEC-0033, FAA-16); the DEC-0031
|
|
10
|
+
merge joins `SUPPORTED_CONTRACT_COMMITS`. Both are additive: new managers, new optional fields, a wider path
|
|
11
|
+
grammar, and SYSTEM or Administrators admitted as a Windows token file's owner.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`fabric_service.py` on Windows** (DEC-0032): the services folder `%LOCALAPPDATA%\passioncode-fabric\services`
|
|
16
|
+
and per-service folders under `%LOCALAPPDATA%`; the instance lock through `msvcrt.locking` on one byte far
|
|
17
|
+
past the pid (released by the OS with the process); token files, descriptors and every kit write carry an
|
|
18
|
+
explicit protected ACL (this user, SYSTEM, Administrators); `read_token` applies service.md "Windows token
|
|
19
|
+
files" — a regular file in the profile, owned by this user, SYSTEM or Administrators (DEC-0033: an elevated
|
|
20
|
+
administrator's files are owned by Administrators, as `windows-latest` showed), every granting ACE on the allow-list, refusal by
|
|
21
|
+
SID; descriptors accept `systemd` (`unit`) and `task-scheduler` (`task`) with the contract's pairing rules,
|
|
22
|
+
Windows paths, and refuse network shares; launchd helpers refuse off macOS instead of calling `os.getuid`;
|
|
23
|
+
the kit's own log and ledger appends open in binary mode. `check_service.py` checks the lock and the
|
|
24
|
+
descriptor's privacy the Windows way.
|
|
25
|
+
- **The operator channel proposal (FAA-15).** `creating-fabric-agents` (step 6) and
|
|
26
|
+
`adapting-projects-to-fabric` (step 8) propose an agent's own messenger channel to its
|
|
27
|
+
operator once, when the agent has human stops or `notify: true` events. They list the
|
|
28
|
+
operator's four steps (a bot from @BotFather, its token in the secret store by name, the
|
|
29
|
+
agent's link command, the code typed in the chat), record the answer (`accepted`, `declined`
|
|
30
|
+
or `later`), and never create a bot, store a token or enable the channel themselves. This is
|
|
31
|
+
rule OC-11 of `fabric-operator-channel/0.1` (contract DEC-0034, contract PR #25). It is newer
|
|
32
|
+
than the default pin, so the skills cite it by path and keep channel conformance `NOT_RUN`
|
|
33
|
+
until the pin moves. New scenario evals: `operator-channel-proposal` in both skills.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Two calls that do not exist or mean something else on Windows**, found by the first Windows run: the usage
|
|
38
|
+
ledger read its last byte with `os.pread` (absent on Windows, so every `record` raised) — it seeks and reads
|
|
39
|
+
there now; and `check_service.py`'s "state inside a release" rule looked for `/releases/` in a path whose
|
|
40
|
+
separators are backslashes — it reads the path's POSIX form.
|
|
41
|
+
|
|
42
|
+
### Added (tests)
|
|
43
|
+
|
|
44
|
+
- **A `windows` job in `validate.yml`** (dispatch, like the others) runs the platform cases on `windows-latest`.
|
|
45
|
+
|
|
46
|
+
## 0.8.1 - 2026-10-08
|
|
47
|
+
|
|
48
|
+
The contract pin moves to Fabric Agent Contract `main` `623bf61` (DEC-0025 through DEC-0031); bundles issued
|
|
49
|
+
under the DEC-0024, DEC-0021, DEC-0020 and DEC-0019 revisions stay valid. And a fix for `Mcp-Name` values outside
|
|
50
|
+
plain ASCII.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **Default contract pin `23f9fda4` → `623bf61358c339cb10297807b3f024b5d9f1f327`** (FAA-14). `23f9fda4` (DEC-0024)
|
|
55
|
+
joins `SUPPORTED_CONTRACT_COMMITS`. The revisions between add only optional surfaces: settings backups and the
|
|
56
|
+
feed token header (DEC-0025), runner routes (DEC-0026, DEC-0029), spending limits in the usage report
|
|
57
|
+
(DEC-0027), activity telemetry (DEC-0030) and devices (DEC-0031). The kits emit none of them yet, which stays
|
|
58
|
+
valid; adopting DEC-0025 and DEC-0027 is FAA-13, runner routes FAA-11. Checked: `npm test`; both compiled-schema
|
|
59
|
+
arms against clean checkouts at `2ce3922` and `623bf61` (11/11); a kit usage report validates against the new
|
|
60
|
+
`service-usage.schema.json`.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- **`Mcp-Name` values outside plain ASCII follow the spec's Base64 sentinel.** MCP 2026-07-28
|
|
65
|
+
Streamable HTTP ("Value Encoding") requires a name or URI with non-ASCII characters, control
|
|
66
|
+
characters, leading or trailing spaces, or the sentinel's own shape to travel as
|
|
67
|
+
`=?base64?<UTF-8 base64>?=`, and a server to decode it before comparing it with the body. Both kits
|
|
68
|
+
sent the raw value and compared the raw header, so such a call was refused by a conforming server
|
|
69
|
+
and a conforming client was refused by ours. `encode_header_value` / `encodeHeaderValue` and
|
|
70
|
+
`decode_header_value` / `decodeHeaderValue` now do both; a malformed sentinel or a raw non-ASCII
|
|
71
|
+
header is refused as `-32020`. Tested with the spec's own five examples in both kits. This is the
|
|
72
|
+
part of PR #24 that FAA-08 had not already shipped.
|
|
73
|
+
|
|
3
74
|
## 0.8.0 - 2026-10-04
|
|
4
75
|
|
|
5
76
|
Agents report what they spend (Fabric Agent Contract DEC-0021), and a service may keep its MCP
|
package/COMMERCIAL-LICENSE.md
CHANGED
|
@@ -5,7 +5,7 @@ This repository is open source under the [GNU Affero General Public License v3.0
|
|
|
5
5
|
meet the AGPL's terms — for example inside a closed-source product, or as a modified hosted
|
|
6
6
|
service whose source is not published.
|
|
7
7
|
|
|
8
|
-
To obtain a commercial license, write to **
|
|
8
|
+
To obtain a commercial license, tell us about your use at **https://passioncode.ai/business/**, or write to **commercial@passioncode.ai**.
|
|
9
9
|
|
|
10
10
|
SPDX: `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial`. Copyright (c) 2026 Siarhei Sheleh.
|
|
11
11
|
Contributions are accepted under [CLA.md](CLA.md), which allows this dual licence.
|
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
191
191
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
192
192
|
|
|
193
193
|
- version: `0.1.0`
|
|
194
|
-
- commit: `
|
|
194
|
+
- commit: `78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5`
|
|
195
195
|
|
|
196
196
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
197
197
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
|
@@ -218,13 +218,13 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
|
|
|
218
218
|
```
|
|
219
219
|
|
|
220
220
|
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
221
|
-
This source checkout is 0.8.0
|
|
222
|
-
|
|
221
|
+
This source checkout is 0.8.2. 0.8.1 is the latest published GitHub/npm release (2026-10-08; 0.7.0 was never
|
|
222
|
+
released); 0.8.2 is published by its `v0.8.2` tag. Source delivery does not update installed skills or live sessions.
|
|
223
223
|
|
|
224
224
|
## License
|
|
225
225
|
|
|
226
226
|
Open source under the [GNU AGPL-3.0](LICENSE). A [commercial license](COMMERCIAL-LICENSE.md) is
|
|
227
|
-
available for use that does not meet the AGPL's terms —
|
|
227
|
+
available for use that does not meet the AGPL's terms — [passioncode.ai/business](https://passioncode.ai/business/).
|
|
228
228
|
Versions before 0.5.3 were released under PolyForm Noncommercial or Internal Use (v0.4.3 to
|
|
229
229
|
v0.5.2) and MIT (v0.4.2 and earlier); each keeps the licence it was released under.
|
|
230
230
|
Contributions are accepted under [CLA.md](CLA.md) ([CONTRIBUTING.md](CONTRIBUTING.md)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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.",
|
|
6
|
-
"version": "0.8.
|
|
6
|
+
"version": "0.8.2",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.8.
|
|
8
|
+
version: "0.8.2"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5`.
|
|
74
74
|
|
|
75
75
|
This is the default for new bundles. When checking an issued legacy bundle, preserve its
|
|
76
76
|
explicit supported lock and use that exact clean checkout. Never silently upgrade or
|
|
@@ -185,6 +185,28 @@ unverified surfaces. If live Fabric admission/runtime does not yet exist, the la
|
|
|
185
185
|
remain `NOT VERIFIED`; the completed deliverable is an adaptation-ready provider bundle,
|
|
186
186
|
not a connected provider.
|
|
187
187
|
|
|
188
|
+
### 8. Propose the operator channel
|
|
189
|
+
|
|
190
|
+
If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
|
|
191
|
+
events, propose its **operator channel** to the operator once, now. The channel is the agent's
|
|
192
|
+
own messenger channel, first Telegram, that sends those events and answers its stops with
|
|
193
|
+
buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
|
|
194
|
+
`docs/specification/operator-channel.md`), whose rule OC-11 this step follows. Give the
|
|
195
|
+
operator's four steps:
|
|
196
|
+
|
|
197
|
+
1. create a bot with @BotFather;
|
|
198
|
+
2. put its token into the operator's secret store under the name the agent reads (for example
|
|
199
|
+
`EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a file or the bundle;
|
|
200
|
+
3. run the agent's link command;
|
|
201
|
+
4. type the code it shows in the chat with the bot.
|
|
202
|
+
|
|
203
|
+
Record the answer — `accepted`, `declined` or `later` — in the agent's state and the
|
|
204
|
+
conformance report, and do not ask again. Creating the bot, storing the token and enabling the
|
|
205
|
+
channel are the operator's action: never create the bot, ask for or store the token's value, or
|
|
206
|
+
enable the channel yourself. DEC-0034 is newer than the default pin: cite it by that path and id, and keep
|
|
207
|
+
channel conformance `NOT_RUN` until the pin carries it. An agent with neither stops nor notify
|
|
208
|
+
events skips this step.
|
|
209
|
+
|
|
188
210
|
## The repository around the provider
|
|
189
211
|
|
|
190
212
|
**For a PassionCode.ai repository**, the adapted project's repository follows the organization's
|
|
@@ -215,6 +237,7 @@ Report:
|
|
|
215
237
|
- contract version and commit;
|
|
216
238
|
- gate table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`;
|
|
217
239
|
- remaining placeholders and blockers;
|
|
240
|
+
- the operator channel answer (step 8), or why it was skipped;
|
|
218
241
|
- the exact next command or runtime action.
|
|
219
242
|
|
|
220
243
|
Never summarize the outcome as “Fabric-compatible” unless all required admission gates
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -16,10 +16,10 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
CONTRACT_VERSION = "0.1.0"
|
|
18
18
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
19
|
-
CONTRACT_COMMIT = "
|
|
19
|
+
CONTRACT_COMMIT = "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5"
|
|
20
20
|
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
21
21
|
# Only this declaration may name a legacy revision in live files.
|
|
22
|
-
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
22
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
23
23
|
# #endregion supported-contract-revisions
|
|
24
24
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
25
25
|
MCP_REVISION = "2026-07-28"
|
|
@@ -16,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
16
16
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). 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.8.
|
|
19
|
+
version: "0.8.2"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
|
-
extension-commit: "
|
|
22
|
+
extension-commit: "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5"
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Building Fabric services
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021). The contract's schemas are normative;
|
|
5
5
|
this page is the working summary. Extension key:
|
|
6
6
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -170,8 +170,14 @@ class Probe:
|
|
|
170
170
|
def descriptor_rules(self) -> None:
|
|
171
171
|
problems = fs.validate_descriptor(self.d)
|
|
172
172
|
self.add("descriptor.valid", "FAIL" if problems else "PASS", "; ".join(problems) or str(self.path))
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
if fs.WINDOWS:
|
|
174
|
+
# DEC-0032: on Windows the file's ACL is its privacy.
|
|
175
|
+
owner, granting = fs.windows_acl(self.path)
|
|
176
|
+
problem = fs.token_acl_problem(self.path, owner, granting, fs.windows_user_sid())
|
|
177
|
+
self.add("descriptor.private", "FAIL" if problem else "PASS", problem or "owner-only ACL")
|
|
178
|
+
else:
|
|
179
|
+
mode = stat.S_IMODE(os.stat(self.path).st_mode)
|
|
180
|
+
self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
|
|
175
181
|
me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
|
|
176
182
|
clashes = []
|
|
177
183
|
for path, other in fs.read_descriptors(self.dir):
|
|
@@ -602,7 +608,7 @@ class Probe:
|
|
|
602
608
|
self.add("lifecycle.priority", "FAIL" if slow else "PASS",
|
|
603
609
|
"; ".join(slow) + " — macOS may starve the service under load and hosts then see an outage; use ProcessType Standard"
|
|
604
610
|
if slow else "%s: scheduled as a standard process" % plist_path.name)
|
|
605
|
-
if not shutil.which("launchctl"):
|
|
611
|
+
if sys.platform != "darwin" or not shutil.which("launchctl"):
|
|
606
612
|
self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
|
|
607
613
|
return
|
|
608
614
|
out = subprocess.run(["launchctl", "print", "gui/%d/%s" % (os.getuid(), life.get("label"))], capture_output=True, text=True)
|
|
@@ -623,7 +629,7 @@ class Probe:
|
|
|
623
629
|
A repository that exists to version the data itself (a registry, a plan) is
|
|
624
630
|
a store, not code; deleting the service's checkout does not touch it."""
|
|
625
631
|
rule = "state.outside-code"
|
|
626
|
-
if "/releases/" in
|
|
632
|
+
if "/releases/" in data.as_posix(): # a Windows path's separators are backslashes
|
|
627
633
|
self.add(rule, "FAIL", "%s is inside a release directory" % data)
|
|
628
634
|
return
|
|
629
635
|
if inside is None:
|
|
@@ -647,6 +653,23 @@ class Probe:
|
|
|
647
653
|
if not lock.exists():
|
|
648
654
|
self.add("lifecycle.instance-lock", "FAIL", "%s does not exist" % lock)
|
|
649
655
|
return
|
|
656
|
+
if fs.WINDOWS:
|
|
657
|
+
import msvcrt
|
|
658
|
+
|
|
659
|
+
# The kit locks one byte at InstanceLock.WINDOWS_LOCK_OFFSET; try the same byte.
|
|
660
|
+
fd = os.open(str(lock), os.O_RDWR | getattr(os, "O_BINARY", 0))
|
|
661
|
+
try:
|
|
662
|
+
os.lseek(fd, fs.InstanceLock.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
|
|
663
|
+
try:
|
|
664
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
665
|
+
except OSError:
|
|
666
|
+
self.add("lifecycle.instance-lock", "PASS", "held by the running service")
|
|
667
|
+
else:
|
|
668
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
669
|
+
self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
|
|
670
|
+
finally:
|
|
671
|
+
os.close(fd)
|
|
672
|
+
return
|
|
650
673
|
try:
|
|
651
674
|
import fcntl
|
|
652
675
|
except ImportError:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -226,6 +226,37 @@ export class JobStore {
|
|
|
226
226
|
export const NAMED_METHODS = { 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' };
|
|
227
227
|
export const HEADER_MISMATCH = -32020; // the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
228
228
|
|
|
229
|
+
const SENTINEL = ['=?base64?', '?='];
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as it
|
|
233
|
+
* is when it is printable ASCII without leading or trailing whitespace, otherwise — and for a plain
|
|
234
|
+
* value that itself looks like the sentinel — `=?base64?<base64 of its UTF-8>?=`.
|
|
235
|
+
*/
|
|
236
|
+
export function encodeHeaderValue(value) {
|
|
237
|
+
const s = String(value);
|
|
238
|
+
const plain = /^[\x20-\x7e]*$/.test(s) && s === s.replace(/^ +| +$/g, '') && !(s.startsWith(SENTINEL[0]) && s.endsWith(SENTINEL[1]));
|
|
239
|
+
if (plain) return s;
|
|
240
|
+
let binary = '';
|
|
241
|
+
for (const byte of new TextEncoder().encode(s)) binary += String.fromCharCode(byte);
|
|
242
|
+
return SENTINEL[0] + btoa(binary) + SENTINEL[1];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The body value a header carries, decoding the Base64 sentinel; null for invalid characters or a
|
|
246
|
+
* sentinel that is not valid Base64 of UTF-8 (a server rejects both). */
|
|
247
|
+
export function decodeHeaderValue(value) {
|
|
248
|
+
const s = String(value);
|
|
249
|
+
if (s.startsWith(SENTINEL[0]) && s.endsWith(SENTINEL[1]) && s.length >= SENTINEL[0].length + SENTINEL[1].length) {
|
|
250
|
+
const body = s.slice(SENTINEL[0].length, -SENTINEL[1].length);
|
|
251
|
+
if (body.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(body)) return null;
|
|
252
|
+
try {
|
|
253
|
+
const bytes = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
|
|
254
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
255
|
+
} catch { return null; }
|
|
256
|
+
}
|
|
257
|
+
return /^[\x20-\x7e\t]*$/.test(s) ? s : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
/**
|
|
230
261
|
* MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
231
262
|
* notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
@@ -250,7 +281,11 @@ export function mcpHeaderProblem(message, headers) {
|
|
|
250
281
|
if (field) {
|
|
251
282
|
const named = header('Mcp-Name');
|
|
252
283
|
if (named === null && declared) return `the body carries params.${field} but the required Mcp-Name header is absent`;
|
|
253
|
-
if (named !== null
|
|
284
|
+
if (named !== null) {
|
|
285
|
+
const decoded = decodeHeaderValue(named);
|
|
286
|
+
if (decoded === null) return 'Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value';
|
|
287
|
+
if (decoded !== String(message.params?.[field])) return `Mcp-Name disagrees with the body's params.${field}`;
|
|
288
|
+
}
|
|
254
289
|
}
|
|
255
290
|
return null;
|
|
256
291
|
}
|
|
@@ -259,6 +294,6 @@ export function mcpHeaderProblem(message, headers) {
|
|
|
259
294
|
export function mcpRequestHeaders(method, params = {}) {
|
|
260
295
|
const out = { 'MCP-Protocol-Version': MCP_REVISION, 'Mcp-Method': method };
|
|
261
296
|
const field = NAMED_METHODS[method];
|
|
262
|
-
if (field && params[field] !== undefined && params[field] !== null) out['Mcp-Name'] =
|
|
297
|
+
if (field && params[field] !== undefined && params[field] !== null) out['Mcp-Name'] = encodeHeaderValue(params[field]);
|
|
263
298
|
return out;
|
|
264
299
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -17,6 +17,7 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import base64
|
|
20
21
|
import json
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
import re
|
|
@@ -386,6 +387,33 @@ NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read":
|
|
|
386
387
|
HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
387
388
|
|
|
388
389
|
|
|
390
|
+
_SENTINEL = ("=?base64?", "?=")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def encode_header_value(value: str) -> str:
|
|
394
|
+
"""A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
|
|
395
|
+
it is when it is printable ASCII without leading or trailing whitespace, otherwise — and for a
|
|
396
|
+
plain value that itself looks like the sentinel — `=?base64?<base64 of its UTF-8>?=`."""
|
|
397
|
+
plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
|
|
398
|
+
and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
|
|
399
|
+
if plain:
|
|
400
|
+
return value
|
|
401
|
+
return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def decode_header_value(value: str) -> Optional[str]:
|
|
405
|
+
"""The body value a header carries, decoding the Base64 sentinel; None for a header value with
|
|
406
|
+
invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
|
|
407
|
+
if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
|
|
408
|
+
try:
|
|
409
|
+
return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
|
|
410
|
+
except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
|
|
411
|
+
return None
|
|
412
|
+
if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
|
|
413
|
+
return None
|
|
414
|
+
return value
|
|
415
|
+
|
|
416
|
+
|
|
389
417
|
def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
390
418
|
"""MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
391
419
|
notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
@@ -414,8 +442,12 @@ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
|
414
442
|
named = header("Mcp-Name")
|
|
415
443
|
if named is None and declared:
|
|
416
444
|
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
417
|
-
if named is not None
|
|
418
|
-
|
|
445
|
+
if named is not None:
|
|
446
|
+
decoded = decode_header_value(named)
|
|
447
|
+
if decoded is None:
|
|
448
|
+
return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
|
|
449
|
+
if decoded != str(value):
|
|
450
|
+
return "Mcp-Name disagrees with the body's params.%s" % field
|
|
419
451
|
return None
|
|
420
452
|
|
|
421
453
|
|
|
@@ -424,7 +456,7 @@ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
|
424
456
|
out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
|
|
425
457
|
field = NAMED_METHODS.get(method)
|
|
426
458
|
if field and params.get(field) is not None:
|
|
427
|
-
out["Mcp-Name"] = str(params[field])
|
|
459
|
+
out["Mcp-Name"] = encode_header_value(str(params[field]))
|
|
428
460
|
return out
|
|
429
461
|
|
|
430
462
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -74,6 +74,16 @@ PLACEMENTS = ("local", "remote")
|
|
|
74
74
|
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
75
75
|
_TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
|
|
76
76
|
_SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
77
|
+
# DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
|
|
78
|
+
# share (`\\server\…`, `\\?\…`) is never a local path.
|
|
79
|
+
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?!/)|[A-Za-z]:[\\/])")
|
|
80
|
+
# DEC-0032: the supervisor per system and the field that names its job.
|
|
81
|
+
MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
|
|
82
|
+
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
|
|
83
|
+
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
|
|
84
|
+
WINDOWS = os.name == "nt"
|
|
85
|
+
# Without O_BINARY a Windows descriptor is in text mode and every LF written becomes CRLF.
|
|
86
|
+
_O_BINARY = getattr(os, "O_BINARY", 0)
|
|
77
87
|
|
|
78
88
|
|
|
79
89
|
class ServiceError(Exception):
|
|
@@ -93,7 +103,10 @@ def now_iso() -> str:
|
|
|
93
103
|
|
|
94
104
|
|
|
95
105
|
def expand(path: str) -> Path:
|
|
96
|
-
"""Paths in a descriptor may start with
|
|
106
|
+
"""Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be
|
|
107
|
+
absolute, and a network share is never a local path (DEC-0032)."""
|
|
108
|
+
if not _LOCAL_PATH.match(str(path)):
|
|
109
|
+
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
97
110
|
p = Path(os.path.expanduser(path))
|
|
98
111
|
if not p.is_absolute():
|
|
99
112
|
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
@@ -110,6 +123,9 @@ def services_dir() -> Path:
|
|
|
110
123
|
home = Path.home()
|
|
111
124
|
if sys.platform == "darwin":
|
|
112
125
|
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
126
|
+
if sys.platform == "win32":
|
|
127
|
+
# DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
|
|
128
|
+
return _local_app_data() / "passioncode-fabric" / "services"
|
|
113
129
|
base = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
|
|
114
130
|
return Path(base) / "passioncode-fabric/services"
|
|
115
131
|
|
|
@@ -124,15 +140,35 @@ def service_dirs(service_id: str) -> Dict[str, Path]:
|
|
|
124
140
|
"logs": home / "Library/Logs" / service_id,
|
|
125
141
|
"cache": home / "Library/Caches" / service_id,
|
|
126
142
|
}
|
|
143
|
+
if sys.platform == "win32":
|
|
144
|
+
data = _local_app_data() / service_id
|
|
145
|
+
return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
|
|
127
146
|
data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
|
|
128
147
|
state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
|
|
129
148
|
cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
|
|
130
149
|
return {"data": data, "logs": state / "logs", "cache": cache}
|
|
131
150
|
|
|
132
151
|
|
|
152
|
+
def _read_at(fd: int, n: int, offset: int) -> bytes:
|
|
153
|
+
"""`os.pread` where it exists; Windows has none, so seek and read (the descriptor's appends
|
|
154
|
+
still go to the end: O_APPEND moves to it before every write)."""
|
|
155
|
+
if hasattr(os, "pread"):
|
|
156
|
+
return os.pread(fd, n, offset)
|
|
157
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
158
|
+
return os.read(fd, n)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _local_app_data() -> Path:
|
|
162
|
+
value = os.environ.get("LOCALAPPDATA")
|
|
163
|
+
return Path(value) if value else Path.home() / "AppData" / "Local"
|
|
164
|
+
|
|
165
|
+
|
|
133
166
|
def ensure_private_dir(path: Path) -> Path:
|
|
134
167
|
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
-
|
|
168
|
+
if WINDOWS:
|
|
169
|
+
_win_protect(path, folder=True)
|
|
170
|
+
else:
|
|
171
|
+
os.chmod(path, 0o700)
|
|
136
172
|
return path
|
|
137
173
|
|
|
138
174
|
|
|
@@ -145,7 +181,12 @@ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
|
145
181
|
handle.write(data)
|
|
146
182
|
handle.flush()
|
|
147
183
|
os.fsync(handle.fileno())
|
|
148
|
-
|
|
184
|
+
if WINDOWS:
|
|
185
|
+
# The file's ACL is its privacy (DEC-0032): set explicitly and protected, so it does
|
|
186
|
+
# not depend on what the folder hands down. A rename keeps it.
|
|
187
|
+
_win_protect(Path(tmp), folder=False)
|
|
188
|
+
else:
|
|
189
|
+
os.chmod(tmp, mode)
|
|
149
190
|
os.replace(tmp, path)
|
|
150
191
|
except BaseException:
|
|
151
192
|
try:
|
|
@@ -169,16 +210,28 @@ class InstanceLock:
|
|
|
169
210
|
self.path = Path(data_dir) / "service.lock"
|
|
170
211
|
self._fd: Optional[int] = None
|
|
171
212
|
|
|
172
|
-
|
|
173
|
-
|
|
213
|
+
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
214
|
+
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
215
|
+
#: a file is allowed there).
|
|
216
|
+
WINDOWS_LOCK_OFFSET = 1 << 30
|
|
174
217
|
|
|
218
|
+
def acquire(self) -> "InstanceLock":
|
|
175
219
|
ensure_private_dir(self.path.parent)
|
|
176
|
-
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
220
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
|
|
177
221
|
try:
|
|
178
|
-
|
|
222
|
+
if WINDOWS:
|
|
223
|
+
import msvcrt
|
|
224
|
+
|
|
225
|
+
os.lseek(fd, self.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
|
|
226
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
227
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
228
|
+
else:
|
|
229
|
+
import fcntl
|
|
230
|
+
|
|
231
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
179
232
|
except OSError as exc:
|
|
180
233
|
os.close(fd)
|
|
181
|
-
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
234
|
+
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
182
235
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
183
236
|
raise
|
|
184
237
|
os.ftruncate(fd, 0)
|
|
@@ -239,6 +292,163 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
239
292
|
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
240
293
|
|
|
241
294
|
|
|
295
|
+
# --- Windows ACLs (DEC-0032) ---------------------------------------------------
|
|
296
|
+
|
|
297
|
+
#: The trustees a token file may grant anything to besides the current user: LocalSystem and the
|
|
298
|
+
#: Administrators group — the same trust as root reading a 0600 file on POSIX.
|
|
299
|
+
WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def token_acl_problem(path: Path, owner: str, granting: Sequence[str], user: str) -> Optional[str]:
|
|
303
|
+
"""service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the
|
|
304
|
+
current user, SYSTEM or Administrators (DEC-0033: a file an elevated administrator creates is
|
|
305
|
+
owned by Administrators), and every SID an allow ACE grants anything to is one of those three.
|
|
306
|
+
Deny ACEs are not passed in: they do not change the decision."""
|
|
307
|
+
if owner != user and owner not in WINDOWS_TRUSTED:
|
|
308
|
+
return "Token file %s is owned by %s, not by this user (%s), SYSTEM or Administrators." % (path, owner or "nobody", user)
|
|
309
|
+
for sid in granting:
|
|
310
|
+
if sid != user and sid not in WINDOWS_TRUSTED:
|
|
311
|
+
return "Token file %s grants access to %s; only this user, SYSTEM and Administrators may hold any." % (path, sid)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
316
|
+
import ctypes
|
|
317
|
+
from ctypes import wintypes
|
|
318
|
+
|
|
319
|
+
_advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
320
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
321
|
+
_PVOID = ctypes.c_void_p
|
|
322
|
+
_SE_FILE_OBJECT, _OWNER_INFO, _DACL_INFO, _PROTECTED_DACL = 1, 0x1, 0x4, 0x80000000
|
|
323
|
+
_REPARSE_POINT = 0x400
|
|
324
|
+
_advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD] + [ctypes.POINTER(_PVOID)] * 5
|
|
325
|
+
_advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
326
|
+
_advapi32.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD] + [_PVOID] * 4
|
|
327
|
+
_advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
328
|
+
_advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW.argtypes = [
|
|
329
|
+
_PVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(wintypes.ULONG)]
|
|
330
|
+
_advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
|
331
|
+
wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_PVOID), ctypes.POINTER(wintypes.ULONG)]
|
|
332
|
+
_advapi32.GetSecurityDescriptorDacl.argtypes = [_PVOID, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_PVOID),
|
|
333
|
+
ctypes.POINTER(wintypes.BOOL)]
|
|
334
|
+
_advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
335
|
+
_advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _PVOID, wintypes.DWORD,
|
|
336
|
+
ctypes.POINTER(wintypes.DWORD)]
|
|
337
|
+
_advapi32.ConvertSidToStringSidW.argtypes = [_PVOID, ctypes.POINTER(wintypes.LPWSTR)]
|
|
338
|
+
_advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_PVOID)]
|
|
339
|
+
_kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
340
|
+
_kernel32.LocalFree.argtypes = [_PVOID]
|
|
341
|
+
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
342
|
+
_ACE = re.compile(r"\(([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*)(?:;[^()]*)?\)")
|
|
343
|
+
_CANONICAL: Dict[str, str] = {}
|
|
344
|
+
|
|
345
|
+
def _win_error(what: str, code: Optional[int] = None) -> OSError:
|
|
346
|
+
code = ctypes.get_last_error() if code is None else code
|
|
347
|
+
return OSError(code, "%s: %s" % (what, ctypes.FormatError(code).strip()))
|
|
348
|
+
|
|
349
|
+
def _sid_text(sid: Any) -> str:
|
|
350
|
+
text = wintypes.LPWSTR()
|
|
351
|
+
if not _advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
|
352
|
+
raise _win_error("ConvertSidToStringSidW")
|
|
353
|
+
try:
|
|
354
|
+
return text.value
|
|
355
|
+
finally:
|
|
356
|
+
_kernel32.LocalFree(text)
|
|
357
|
+
|
|
358
|
+
def _canonical_sid(trustee: str) -> str:
|
|
359
|
+
"""SDDL writes well-known accounts by alias (SY, BA, LA, OW); compare SIDs."""
|
|
360
|
+
if trustee not in _CANONICAL:
|
|
361
|
+
sid = _PVOID()
|
|
362
|
+
if _advapi32.ConvertStringSidToSidW(trustee, ctypes.byref(sid)):
|
|
363
|
+
try:
|
|
364
|
+
_CANONICAL[trustee] = _sid_text(sid)
|
|
365
|
+
finally:
|
|
366
|
+
_kernel32.LocalFree(sid)
|
|
367
|
+
else:
|
|
368
|
+
_CANONICAL[trustee] = trustee
|
|
369
|
+
return _CANONICAL[trustee]
|
|
370
|
+
|
|
371
|
+
def windows_user_sid() -> str:
|
|
372
|
+
token = wintypes.HANDLE()
|
|
373
|
+
if not _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
|
|
374
|
+
raise _win_error("OpenProcessToken")
|
|
375
|
+
try:
|
|
376
|
+
size = wintypes.DWORD()
|
|
377
|
+
_advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
|
|
378
|
+
buf = ctypes.create_string_buffer(size.value)
|
|
379
|
+
if not _advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
|
|
380
|
+
raise _win_error("GetTokenInformation")
|
|
381
|
+
return _sid_text(ctypes.cast(buf, ctypes.POINTER(_PVOID))[0])
|
|
382
|
+
finally:
|
|
383
|
+
_kernel32.CloseHandle(token)
|
|
384
|
+
|
|
385
|
+
def _sddl(path: Path) -> str:
|
|
386
|
+
sd = _PVOID()
|
|
387
|
+
refs = [_PVOID() for _ in range(4)]
|
|
388
|
+
code = _advapi32.GetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _OWNER_INFO | _DACL_INFO,
|
|
389
|
+
*[ctypes.byref(r) for r in refs], ctypes.byref(sd))
|
|
390
|
+
if code:
|
|
391
|
+
raise _win_error("GetNamedSecurityInfoW", code)
|
|
392
|
+
try:
|
|
393
|
+
text = wintypes.LPWSTR()
|
|
394
|
+
if not _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW(
|
|
395
|
+
sd, 1, _OWNER_INFO | _DACL_INFO, ctypes.byref(text), None):
|
|
396
|
+
raise _win_error("ConvertSecurityDescriptorToStringSecurityDescriptorW")
|
|
397
|
+
try:
|
|
398
|
+
return text.value or ""
|
|
399
|
+
finally:
|
|
400
|
+
_kernel32.LocalFree(text)
|
|
401
|
+
finally:
|
|
402
|
+
_kernel32.LocalFree(sd)
|
|
403
|
+
|
|
404
|
+
def windows_acl(path: Path) -> Tuple[str, List[str]]:
|
|
405
|
+
"""(owner SID, SIDs of every allow ACE on the object itself); a missing or NULL list grants
|
|
406
|
+
everyone everything, reported as Everyone (S-1-1-0)."""
|
|
407
|
+
text = _sddl(path)
|
|
408
|
+
owner = text.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in text else ""
|
|
409
|
+
dacl = text.split("D:", 1)[1] if "D:" in text else ""
|
|
410
|
+
if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
|
|
411
|
+
return _canonical_sid(owner) if owner else "", ["S-1-1-0"]
|
|
412
|
+
granting = [_canonical_sid(trustee) for kind, flags, _r, _o, _i, trustee in _ACE.findall(dacl)
|
|
413
|
+
if kind in ("A", "OA", "XA", "ZA") and "IO" not in flags]
|
|
414
|
+
return (_canonical_sid(owner) if owner else ""), granting
|
|
415
|
+
|
|
416
|
+
def _win_protect(path: Path, folder: bool) -> None:
|
|
417
|
+
"""A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
|
|
418
|
+
inherited by what a folder holds."""
|
|
419
|
+
inherit = "OICI" if folder else ""
|
|
420
|
+
sddl = "D:P" + "".join("(A;%s;FA;;;%s)" % (inherit, who) for who in (windows_user_sid(), "SY", "BA"))
|
|
421
|
+
sd = _PVOID()
|
|
422
|
+
if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(sd), None):
|
|
423
|
+
raise _win_error("ConvertStringSecurityDescriptorToSecurityDescriptorW")
|
|
424
|
+
try:
|
|
425
|
+
present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _PVOID()
|
|
426
|
+
if not _advapi32.GetSecurityDescriptorDacl(sd, ctypes.byref(present), ctypes.byref(dacl), ctypes.byref(defaulted)):
|
|
427
|
+
raise _win_error("GetSecurityDescriptorDacl")
|
|
428
|
+
code = _advapi32.SetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _DACL_INFO | _PROTECTED_DACL,
|
|
429
|
+
None, None, dacl, None)
|
|
430
|
+
if code:
|
|
431
|
+
raise _win_error("SetNamedSecurityInfoW", code)
|
|
432
|
+
finally:
|
|
433
|
+
_kernel32.LocalFree(sd)
|
|
434
|
+
|
|
435
|
+
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
|
|
436
|
+
if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
|
|
437
|
+
return "Token file %s is not a regular file (a link or junction); refusing it." % path
|
|
438
|
+
profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
|
|
439
|
+
real = os.path.normcase(os.path.realpath(str(path)))
|
|
440
|
+
if os.path.commonpath([profile, real]) != profile:
|
|
441
|
+
return "Token file %s lies outside the user's profile; refusing it." % path
|
|
442
|
+
owner, granting = windows_acl(path)
|
|
443
|
+
return token_acl_problem(path, owner, granting, windows_user_sid())
|
|
444
|
+
else:
|
|
445
|
+
def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
|
|
446
|
+
raise ServiceError("Windows ACLs exist only on Windows")
|
|
447
|
+
|
|
448
|
+
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]: # noqa: ARG001
|
|
449
|
+
raise ServiceError("Windows token rules apply only on Windows")
|
|
450
|
+
|
|
451
|
+
|
|
242
452
|
# --- token -------------------------------------------------------------------
|
|
243
453
|
|
|
244
454
|
def ensure_token(path: Path) -> str:
|
|
@@ -253,15 +463,24 @@ def ensure_token(path: Path) -> str:
|
|
|
253
463
|
|
|
254
464
|
|
|
255
465
|
def read_token(path: Path) -> str:
|
|
256
|
-
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
466
|
+
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
467
|
+
|
|
468
|
+
On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033): a regular file
|
|
469
|
+
whose real path is in the profile, owned by the current user, SYSTEM or Administrators, every
|
|
470
|
+
granting ACE naming one of those three; a refusal names the SID, never the contents."""
|
|
257
471
|
path = Path(path)
|
|
258
472
|
info = os.lstat(path)
|
|
259
473
|
if stat.S_ISLNK(info.st_mode):
|
|
260
474
|
raise ServiceError("Token file %s is a symlink; refusing it." % path)
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
475
|
+
if WINDOWS:
|
|
476
|
+
problem = _win_token_problem(path, info)
|
|
477
|
+
if problem:
|
|
478
|
+
raise ServiceError(problem)
|
|
479
|
+
else:
|
|
480
|
+
if info.st_uid != os.getuid():
|
|
481
|
+
raise ServiceError("Token file %s belongs to another user." % path)
|
|
482
|
+
if info.st_mode & 0o077:
|
|
483
|
+
raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
|
|
265
484
|
token = path.read_text().strip()
|
|
266
485
|
if len(token) < 16:
|
|
267
486
|
raise ServiceError("Token file %s holds no usable token." % path)
|
|
@@ -354,21 +573,38 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
354
573
|
auth = descriptor.get("auth") or {}
|
|
355
574
|
if "tokenFile" not in auth:
|
|
356
575
|
problems.append("auth.tokenFile is required")
|
|
576
|
+
elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
|
|
577
|
+
problems.append("auth.tokenFile must be a local path, never a network share")
|
|
578
|
+
paths = descriptor.get("paths") or {}
|
|
579
|
+
for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
|
|
580
|
+
if not _LOCAL_PATH.match(str(value)):
|
|
581
|
+
problems.append("paths must be local paths, never a network share: %r" % value)
|
|
357
582
|
header = auth.get("header", "Authorization")
|
|
358
583
|
scheme = auth.get("scheme", "Bearer")
|
|
359
584
|
if header != "Authorization" and scheme != "none":
|
|
360
585
|
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
361
586
|
life = descriptor.get("lifecycle") or {}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
587
|
+
manager = life.get("manager")
|
|
588
|
+
if manager not in MANAGERS:
|
|
589
|
+
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
|
|
590
|
+
if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
365
591
|
problems.append("a launchd service declares label and plist")
|
|
592
|
+
if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
|
|
593
|
+
problems.append("lifecycle.plist must be a local path")
|
|
594
|
+
# DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
595
|
+
for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
|
|
596
|
+
if manager == owner and field not in life:
|
|
597
|
+
problems.append("a %s service declares %s" % (owner, field))
|
|
598
|
+
if field in life and manager != owner:
|
|
599
|
+
problems.append("lifecycle.%s belongs to manager %s only" % (field, owner))
|
|
600
|
+
if field in life and not pattern.match(str(life[field])):
|
|
601
|
+
problems.append("lifecycle.%s must match %s" % (field, pattern.pattern))
|
|
366
602
|
if remote:
|
|
367
|
-
if
|
|
603
|
+
if manager != "none":
|
|
368
604
|
problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
|
|
369
|
-
for field in ("label", "plist"):
|
|
605
|
+
for field, of in (("label", "launchd"), ("plist", "launchd"), ("unit", "systemd"), ("task", "Task Scheduler")):
|
|
370
606
|
if field in life:
|
|
371
|
-
problems.append("a remote service has no
|
|
607
|
+
problems.append("a remote service has no %s %s" % (of, field))
|
|
372
608
|
if "update" in (descriptor.get("commands") or {}):
|
|
373
609
|
problems.append("a remote service declares no update command")
|
|
374
610
|
for name, argv in (descriptor.get("commands") or {}).items():
|
|
@@ -376,7 +612,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
376
612
|
problems.append("unknown command %s" % name)
|
|
377
613
|
elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
|
378
614
|
problems.append("command %s must be an argument array" % name)
|
|
379
|
-
elif not
|
|
615
|
+
elif not _LOCAL_PATH.match(argv[0]):
|
|
380
616
|
problems.append("command %s must start with an absolute or ~/ executable" % name)
|
|
381
617
|
return problems
|
|
382
618
|
|
|
@@ -699,10 +935,10 @@ class JsonlUsageLedger:
|
|
|
699
935
|
line = json.dumps(receipt, separators=(",", ":")) + "\n"
|
|
700
936
|
with self._lock:
|
|
701
937
|
ensure_private_dir(self.path.parent)
|
|
702
|
-
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
|
|
938
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND | _O_BINARY, 0o600)
|
|
703
939
|
try:
|
|
704
940
|
size = os.fstat(fd).st_size
|
|
705
|
-
if size and
|
|
941
|
+
if size and _read_at(fd, 1, size - 1) != b"\n":
|
|
706
942
|
line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
|
|
707
943
|
os.write(fd, line.encode("utf-8"))
|
|
708
944
|
finally:
|
|
@@ -779,7 +1015,7 @@ class RotatingLog:
|
|
|
779
1015
|
size = 0
|
|
780
1016
|
if size and size + len(data) > self.max_bytes:
|
|
781
1017
|
self._rotate()
|
|
782
|
-
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
1018
|
+
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
|
|
783
1019
|
try:
|
|
784
1020
|
os.write(fd, data)
|
|
785
1021
|
finally:
|
|
@@ -1114,6 +1350,9 @@ def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
|
1114
1350
|
|
|
1115
1351
|
|
|
1116
1352
|
def _domain() -> str:
|
|
1353
|
+
if sys.platform == "win32":
|
|
1354
|
+
# DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
|
|
1355
|
+
raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
|
|
1117
1356
|
return "gui/%d" % os.getuid()
|
|
1118
1357
|
|
|
1119
1358
|
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.8.
|
|
8
|
+
version: "0.8.2"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -119,7 +119,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
119
119
|
--schema-base <immutable-base-uri>
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
Pin exactly contract `0.1.0` at commit `
|
|
122
|
+
Pin exactly contract `0.1.0` at commit `78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5` and
|
|
123
123
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
124
124
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
125
125
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -164,13 +164,35 @@ purpose. Then record one expectation the report must carry:
|
|
|
164
164
|
> checker on its output and a budget cap — regardless of who wrote it. Unsupervised
|
|
165
165
|
> operation is a later, recorded promotion citing eval results and run history.
|
|
166
166
|
|
|
167
|
+
## Step 6 — propose the operator channel
|
|
168
|
+
|
|
169
|
+
If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
|
|
170
|
+
events, propose its **operator channel** to the operator once, now. The channel is the agent's
|
|
171
|
+
own messenger channel, first Telegram, that sends those events and answers its stops with
|
|
172
|
+
buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
|
|
173
|
+
`docs/specification/operator-channel.md`), whose rule OC-11 this step follows. Give the
|
|
174
|
+
operator's four steps:
|
|
175
|
+
|
|
176
|
+
1. create a bot with @BotFather;
|
|
177
|
+
2. put its token into the secret store (Project Observatory) under the name the agent reads
|
|
178
|
+
(for example `EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a file or the bundle;
|
|
179
|
+
3. run the agent's link command;
|
|
180
|
+
4. type the code it shows in the chat with the bot.
|
|
181
|
+
|
|
182
|
+
Record the answer — `accepted`, `declined` or `later` — in the agent's state and in the
|
|
183
|
+
report, and do not ask again. Creating the bot, storing the token and enabling the channel are the
|
|
184
|
+
operator's action: never create the bot, ask for or store the token's value, or enable the
|
|
185
|
+
channel yourself. DEC-0034 is newer than the default pin: cite it by that path and id, and keep
|
|
186
|
+
channel conformance `NOT_RUN` until the pin carries it. An agent with neither stops nor notify
|
|
187
|
+
events skips this step.
|
|
188
|
+
|
|
167
189
|
## Completion format
|
|
168
190
|
|
|
169
191
|
Report: capability, consumer, profile with the step-0 grill answers; the knowledge pack
|
|
170
192
|
and which traps became fixtures; created files; contract version and commit; the gate
|
|
171
|
-
table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`; remaining placeholders;
|
|
172
|
-
exact next command. Never summarize the outcome as
|
|
173
|
-
required admission gate passed against the real provider.
|
|
193
|
+
table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`; remaining placeholders; the
|
|
194
|
+
operator channel answer (step 6); and the exact next command. Never summarize the outcome as
|
|
195
|
+
"Fabric-compatible" unless every required admission gate passed against the real provider.
|
|
174
196
|
|
|
175
197
|
## Dashboard handoff — also for consumers
|
|
176
198
|
|