@passioncode-ai/fabric-agent-adapter 0.8.1 → 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 CHANGED
@@ -1,5 +1,48 @@
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
+
3
46
  ## 0.8.1 - 2026-10-08
4
47
 
5
48
  The contract pin moves to Fabric Agent Contract `main` `623bf61` (DEC-0025 through DEC-0031); bundles issued
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: `623bf61358c339cb10297807b3f024b5d9f1f327`
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,8 +218,8 @@ 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.1. 0.8.0 is the latest published GitHub/npm release (2026-10-05; 0.7.0 was never
222
- released); 0.8.1 is published by its `v0.8.1` tag. Source delivery does not update installed skills or live sessions.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.8.1",
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.1",
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.1"
8
+ version: "0.8.2"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "623bf61358c339cb10297807b3f024b5d9f1f327"
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 `623bf61358c339cb10297807b3f024b5d9f1f327`.
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
@@ -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 = "623bf61358c339cb10297807b3f024b5d9f1f327"
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, "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "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.1"
19
+ version: "0.8.2"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
- extension-commit: "623bf61358c339cb10297807b3f024b5d9f1f327"
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 `623bf61358c339cb10297807b3f024b5d9f1f327`
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`.
@@ -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
- mode = stat.S_IMODE(os.stat(self.path).st_mode)
174
- self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
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 str(data):
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:
@@ -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 ~/; everything else must be absolute."""
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
- os.chmod(path, 0o700)
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
- os.chmod(tmp, mode)
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
- def acquire(self) -> "InstanceLock":
173
- import fcntl
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
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
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 info.st_uid != os.getuid():
262
- raise ServiceError("Token file %s belongs to another user." % path)
263
- if info.st_mode & 0o077:
264
- raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
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
- if life.get("manager") not in ("launchd", "none"):
363
- problems.append("lifecycle.manager must be launchd or none")
364
- if life.get("manager") == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
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 life.get("manager") != "none":
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 launchd %s" % field)
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 re.match(r"^(~/|/)", argv[0]):
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 os.pread(fd, 1, size - 1) != b"\n":
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.1"
8
+ version: "0.8.2"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "623bf61358c339cb10297807b3f024b5d9f1f327"
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 `623bf61358c339cb10297807b3f024b5d9f1f327` and
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; and the
172
- exact next command. Never summarize the outcome as "Fabric-compatible" unless every
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