@passioncode-ai/passioncode 0.1.31 → 0.1.33
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 +26 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +18 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +58 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +51 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +327 -25
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +21 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +35 -6
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -40,6 +40,11 @@ import urllib.request
|
|
|
40
40
|
|
|
41
41
|
PROTOCOL = "fabric-service/0.1"
|
|
42
42
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
43
|
+
# The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
44
|
+
# document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
45
|
+
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
46
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.0.0"
|
|
47
|
+
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
43
48
|
EXIT_ALREADY_RUNNING = 75
|
|
44
49
|
# Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
|
|
45
50
|
# the lock held backs off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive
|
|
@@ -74,6 +79,16 @@ PLACEMENTS = ("local", "remote")
|
|
|
74
79
|
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
75
80
|
_TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
|
|
76
81
|
_SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
82
|
+
# DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
|
|
83
|
+
# share (`\\server\…`, `\\?\…`) is never a local path.
|
|
84
|
+
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?!/)|[A-Za-z]:[\\/])")
|
|
85
|
+
# DEC-0032: the supervisor per system and the field that names its job.
|
|
86
|
+
MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
|
|
87
|
+
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
|
|
88
|
+
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
|
|
89
|
+
WINDOWS = os.name == "nt"
|
|
90
|
+
# Without O_BINARY a Windows descriptor is in text mode and every LF written becomes CRLF.
|
|
91
|
+
_O_BINARY = getattr(os, "O_BINARY", 0)
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
class ServiceError(Exception):
|
|
@@ -93,7 +108,10 @@ def now_iso() -> str:
|
|
|
93
108
|
|
|
94
109
|
|
|
95
110
|
def expand(path: str) -> Path:
|
|
96
|
-
"""Paths in a descriptor may start with
|
|
111
|
+
"""Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be
|
|
112
|
+
absolute, and a network share is never a local path (DEC-0032)."""
|
|
113
|
+
if not _LOCAL_PATH.match(str(path)):
|
|
114
|
+
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
97
115
|
p = Path(os.path.expanduser(path))
|
|
98
116
|
if not p.is_absolute():
|
|
99
117
|
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
@@ -110,6 +128,9 @@ def services_dir() -> Path:
|
|
|
110
128
|
home = Path.home()
|
|
111
129
|
if sys.platform == "darwin":
|
|
112
130
|
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
131
|
+
if sys.platform == "win32":
|
|
132
|
+
# DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
|
|
133
|
+
return _local_app_data() / "passioncode-fabric" / "services"
|
|
113
134
|
base = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
|
|
114
135
|
return Path(base) / "passioncode-fabric/services"
|
|
115
136
|
|
|
@@ -124,15 +145,35 @@ def service_dirs(service_id: str) -> Dict[str, Path]:
|
|
|
124
145
|
"logs": home / "Library/Logs" / service_id,
|
|
125
146
|
"cache": home / "Library/Caches" / service_id,
|
|
126
147
|
}
|
|
148
|
+
if sys.platform == "win32":
|
|
149
|
+
data = _local_app_data() / service_id
|
|
150
|
+
return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
|
|
127
151
|
data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
|
|
128
152
|
state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
|
|
129
153
|
cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
|
|
130
154
|
return {"data": data, "logs": state / "logs", "cache": cache}
|
|
131
155
|
|
|
132
156
|
|
|
157
|
+
def _read_at(fd: int, n: int, offset: int) -> bytes:
|
|
158
|
+
"""`os.pread` where it exists; Windows has none, so seek and read (the descriptor's appends
|
|
159
|
+
still go to the end: O_APPEND moves to it before every write)."""
|
|
160
|
+
if hasattr(os, "pread"):
|
|
161
|
+
return os.pread(fd, n, offset)
|
|
162
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
163
|
+
return os.read(fd, n)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _local_app_data() -> Path:
|
|
167
|
+
value = os.environ.get("LOCALAPPDATA")
|
|
168
|
+
return Path(value) if value else Path.home() / "AppData" / "Local"
|
|
169
|
+
|
|
170
|
+
|
|
133
171
|
def ensure_private_dir(path: Path) -> Path:
|
|
134
172
|
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
-
|
|
173
|
+
if WINDOWS:
|
|
174
|
+
_win_protect(path, folder=True)
|
|
175
|
+
else:
|
|
176
|
+
os.chmod(path, 0o700)
|
|
136
177
|
return path
|
|
137
178
|
|
|
138
179
|
|
|
@@ -145,7 +186,12 @@ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
|
145
186
|
handle.write(data)
|
|
146
187
|
handle.flush()
|
|
147
188
|
os.fsync(handle.fileno())
|
|
148
|
-
|
|
189
|
+
if WINDOWS:
|
|
190
|
+
# The file's ACL is its privacy (DEC-0032): set explicitly and protected, so it does
|
|
191
|
+
# not depend on what the folder hands down. A rename keeps it.
|
|
192
|
+
_win_protect(Path(tmp), folder=False)
|
|
193
|
+
else:
|
|
194
|
+
os.chmod(tmp, mode)
|
|
149
195
|
os.replace(tmp, path)
|
|
150
196
|
except BaseException:
|
|
151
197
|
try:
|
|
@@ -169,16 +215,28 @@ class InstanceLock:
|
|
|
169
215
|
self.path = Path(data_dir) / "service.lock"
|
|
170
216
|
self._fd: Optional[int] = None
|
|
171
217
|
|
|
172
|
-
|
|
173
|
-
|
|
218
|
+
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
219
|
+
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
220
|
+
#: a file is allowed there).
|
|
221
|
+
WINDOWS_LOCK_OFFSET = 1 << 30
|
|
174
222
|
|
|
223
|
+
def acquire(self) -> "InstanceLock":
|
|
175
224
|
ensure_private_dir(self.path.parent)
|
|
176
|
-
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
225
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
|
|
177
226
|
try:
|
|
178
|
-
|
|
227
|
+
if WINDOWS:
|
|
228
|
+
import msvcrt
|
|
229
|
+
|
|
230
|
+
os.lseek(fd, self.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
|
|
231
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
232
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
233
|
+
else:
|
|
234
|
+
import fcntl
|
|
235
|
+
|
|
236
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
179
237
|
except OSError as exc:
|
|
180
238
|
os.close(fd)
|
|
181
|
-
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
239
|
+
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
182
240
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
183
241
|
raise
|
|
184
242
|
os.ftruncate(fd, 0)
|
|
@@ -239,6 +297,163 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
239
297
|
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
240
298
|
|
|
241
299
|
|
|
300
|
+
# --- Windows ACLs (DEC-0032) ---------------------------------------------------
|
|
301
|
+
|
|
302
|
+
#: The trustees a token file may grant anything to besides the current user: LocalSystem and the
|
|
303
|
+
#: Administrators group — the same trust as root reading a 0600 file on POSIX.
|
|
304
|
+
WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def token_acl_problem(path: Path, owner: str, granting: Sequence[str], user: str) -> Optional[str]:
|
|
308
|
+
"""service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the
|
|
309
|
+
current user, SYSTEM or Administrators (DEC-0033: a file an elevated administrator creates is
|
|
310
|
+
owned by Administrators), and every SID an allow ACE grants anything to is one of those three.
|
|
311
|
+
Deny ACEs are not passed in: they do not change the decision."""
|
|
312
|
+
if owner != user and owner not in WINDOWS_TRUSTED:
|
|
313
|
+
return "Token file %s is owned by %s, not by this user (%s), SYSTEM or Administrators." % (path, owner or "nobody", user)
|
|
314
|
+
for sid in granting:
|
|
315
|
+
if sid != user and sid not in WINDOWS_TRUSTED:
|
|
316
|
+
return "Token file %s grants access to %s; only this user, SYSTEM and Administrators may hold any." % (path, sid)
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
321
|
+
import ctypes
|
|
322
|
+
from ctypes import wintypes
|
|
323
|
+
|
|
324
|
+
_advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
325
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
326
|
+
_PVOID = ctypes.c_void_p
|
|
327
|
+
_SE_FILE_OBJECT, _OWNER_INFO, _DACL_INFO, _PROTECTED_DACL = 1, 0x1, 0x4, 0x80000000
|
|
328
|
+
_REPARSE_POINT = 0x400
|
|
329
|
+
_advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD] + [ctypes.POINTER(_PVOID)] * 5
|
|
330
|
+
_advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
331
|
+
_advapi32.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD] + [_PVOID] * 4
|
|
332
|
+
_advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
333
|
+
_advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW.argtypes = [
|
|
334
|
+
_PVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(wintypes.ULONG)]
|
|
335
|
+
_advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
|
336
|
+
wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_PVOID), ctypes.POINTER(wintypes.ULONG)]
|
|
337
|
+
_advapi32.GetSecurityDescriptorDacl.argtypes = [_PVOID, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_PVOID),
|
|
338
|
+
ctypes.POINTER(wintypes.BOOL)]
|
|
339
|
+
_advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
340
|
+
_advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _PVOID, wintypes.DWORD,
|
|
341
|
+
ctypes.POINTER(wintypes.DWORD)]
|
|
342
|
+
_advapi32.ConvertSidToStringSidW.argtypes = [_PVOID, ctypes.POINTER(wintypes.LPWSTR)]
|
|
343
|
+
_advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_PVOID)]
|
|
344
|
+
_kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
345
|
+
_kernel32.LocalFree.argtypes = [_PVOID]
|
|
346
|
+
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
347
|
+
_ACE = re.compile(r"\(([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*);([^;()]*)(?:;[^()]*)?\)")
|
|
348
|
+
_CANONICAL: Dict[str, str] = {}
|
|
349
|
+
|
|
350
|
+
def _win_error(what: str, code: Optional[int] = None) -> OSError:
|
|
351
|
+
code = ctypes.get_last_error() if code is None else code
|
|
352
|
+
return OSError(code, "%s: %s" % (what, ctypes.FormatError(code).strip()))
|
|
353
|
+
|
|
354
|
+
def _sid_text(sid: Any) -> str:
|
|
355
|
+
text = wintypes.LPWSTR()
|
|
356
|
+
if not _advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
|
357
|
+
raise _win_error("ConvertSidToStringSidW")
|
|
358
|
+
try:
|
|
359
|
+
return text.value
|
|
360
|
+
finally:
|
|
361
|
+
_kernel32.LocalFree(text)
|
|
362
|
+
|
|
363
|
+
def _canonical_sid(trustee: str) -> str:
|
|
364
|
+
"""SDDL writes well-known accounts by alias (SY, BA, LA, OW); compare SIDs."""
|
|
365
|
+
if trustee not in _CANONICAL:
|
|
366
|
+
sid = _PVOID()
|
|
367
|
+
if _advapi32.ConvertStringSidToSidW(trustee, ctypes.byref(sid)):
|
|
368
|
+
try:
|
|
369
|
+
_CANONICAL[trustee] = _sid_text(sid)
|
|
370
|
+
finally:
|
|
371
|
+
_kernel32.LocalFree(sid)
|
|
372
|
+
else:
|
|
373
|
+
_CANONICAL[trustee] = trustee
|
|
374
|
+
return _CANONICAL[trustee]
|
|
375
|
+
|
|
376
|
+
def windows_user_sid() -> str:
|
|
377
|
+
token = wintypes.HANDLE()
|
|
378
|
+
if not _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
|
|
379
|
+
raise _win_error("OpenProcessToken")
|
|
380
|
+
try:
|
|
381
|
+
size = wintypes.DWORD()
|
|
382
|
+
_advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
|
|
383
|
+
buf = ctypes.create_string_buffer(size.value)
|
|
384
|
+
if not _advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
|
|
385
|
+
raise _win_error("GetTokenInformation")
|
|
386
|
+
return _sid_text(ctypes.cast(buf, ctypes.POINTER(_PVOID))[0])
|
|
387
|
+
finally:
|
|
388
|
+
_kernel32.CloseHandle(token)
|
|
389
|
+
|
|
390
|
+
def _sddl(path: Path) -> str:
|
|
391
|
+
sd = _PVOID()
|
|
392
|
+
refs = [_PVOID() for _ in range(4)]
|
|
393
|
+
code = _advapi32.GetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _OWNER_INFO | _DACL_INFO,
|
|
394
|
+
*[ctypes.byref(r) for r in refs], ctypes.byref(sd))
|
|
395
|
+
if code:
|
|
396
|
+
raise _win_error("GetNamedSecurityInfoW", code)
|
|
397
|
+
try:
|
|
398
|
+
text = wintypes.LPWSTR()
|
|
399
|
+
if not _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW(
|
|
400
|
+
sd, 1, _OWNER_INFO | _DACL_INFO, ctypes.byref(text), None):
|
|
401
|
+
raise _win_error("ConvertSecurityDescriptorToStringSecurityDescriptorW")
|
|
402
|
+
try:
|
|
403
|
+
return text.value or ""
|
|
404
|
+
finally:
|
|
405
|
+
_kernel32.LocalFree(text)
|
|
406
|
+
finally:
|
|
407
|
+
_kernel32.LocalFree(sd)
|
|
408
|
+
|
|
409
|
+
def windows_acl(path: Path) -> Tuple[str, List[str]]:
|
|
410
|
+
"""(owner SID, SIDs of every allow ACE on the object itself); a missing or NULL list grants
|
|
411
|
+
everyone everything, reported as Everyone (S-1-1-0)."""
|
|
412
|
+
text = _sddl(path)
|
|
413
|
+
owner = text.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in text else ""
|
|
414
|
+
dacl = text.split("D:", 1)[1] if "D:" in text else ""
|
|
415
|
+
if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
|
|
416
|
+
return _canonical_sid(owner) if owner else "", ["S-1-1-0"]
|
|
417
|
+
granting = [_canonical_sid(trustee) for kind, flags, _r, _o, _i, trustee in _ACE.findall(dacl)
|
|
418
|
+
if kind in ("A", "OA", "XA", "ZA") and "IO" not in flags]
|
|
419
|
+
return (_canonical_sid(owner) if owner else ""), granting
|
|
420
|
+
|
|
421
|
+
def _win_protect(path: Path, folder: bool) -> None:
|
|
422
|
+
"""A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
|
|
423
|
+
inherited by what a folder holds."""
|
|
424
|
+
inherit = "OICI" if folder else ""
|
|
425
|
+
sddl = "D:P" + "".join("(A;%s;FA;;;%s)" % (inherit, who) for who in (windows_user_sid(), "SY", "BA"))
|
|
426
|
+
sd = _PVOID()
|
|
427
|
+
if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(sd), None):
|
|
428
|
+
raise _win_error("ConvertStringSecurityDescriptorToSecurityDescriptorW")
|
|
429
|
+
try:
|
|
430
|
+
present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _PVOID()
|
|
431
|
+
if not _advapi32.GetSecurityDescriptorDacl(sd, ctypes.byref(present), ctypes.byref(dacl), ctypes.byref(defaulted)):
|
|
432
|
+
raise _win_error("GetSecurityDescriptorDacl")
|
|
433
|
+
code = _advapi32.SetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _DACL_INFO | _PROTECTED_DACL,
|
|
434
|
+
None, None, dacl, None)
|
|
435
|
+
if code:
|
|
436
|
+
raise _win_error("SetNamedSecurityInfoW", code)
|
|
437
|
+
finally:
|
|
438
|
+
_kernel32.LocalFree(sd)
|
|
439
|
+
|
|
440
|
+
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
|
|
441
|
+
if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
|
|
442
|
+
return "Token file %s is not a regular file (a link or junction); refusing it." % path
|
|
443
|
+
profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
|
|
444
|
+
real = os.path.normcase(os.path.realpath(str(path)))
|
|
445
|
+
if os.path.commonpath([profile, real]) != profile:
|
|
446
|
+
return "Token file %s lies outside the user's profile; refusing it." % path
|
|
447
|
+
owner, granting = windows_acl(path)
|
|
448
|
+
return token_acl_problem(path, owner, granting, windows_user_sid())
|
|
449
|
+
else:
|
|
450
|
+
def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
|
|
451
|
+
raise ServiceError("Windows ACLs exist only on Windows")
|
|
452
|
+
|
|
453
|
+
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]: # noqa: ARG001
|
|
454
|
+
raise ServiceError("Windows token rules apply only on Windows")
|
|
455
|
+
|
|
456
|
+
|
|
242
457
|
# --- token -------------------------------------------------------------------
|
|
243
458
|
|
|
244
459
|
def ensure_token(path: Path) -> str:
|
|
@@ -253,15 +468,24 @@ def ensure_token(path: Path) -> str:
|
|
|
253
468
|
|
|
254
469
|
|
|
255
470
|
def read_token(path: Path) -> str:
|
|
256
|
-
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
471
|
+
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
472
|
+
|
|
473
|
+
On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033): a regular file
|
|
474
|
+
whose real path is in the profile, owned by the current user, SYSTEM or Administrators, every
|
|
475
|
+
granting ACE naming one of those three; a refusal names the SID, never the contents."""
|
|
257
476
|
path = Path(path)
|
|
258
477
|
info = os.lstat(path)
|
|
259
478
|
if stat.S_ISLNK(info.st_mode):
|
|
260
479
|
raise ServiceError("Token file %s is a symlink; refusing it." % path)
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
480
|
+
if WINDOWS:
|
|
481
|
+
problem = _win_token_problem(path, info)
|
|
482
|
+
if problem:
|
|
483
|
+
raise ServiceError(problem)
|
|
484
|
+
else:
|
|
485
|
+
if info.st_uid != os.getuid():
|
|
486
|
+
raise ServiceError("Token file %s belongs to another user." % path)
|
|
487
|
+
if info.st_mode & 0o077:
|
|
488
|
+
raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
|
|
265
489
|
token = path.read_text().strip()
|
|
266
490
|
if len(token) < 16:
|
|
267
491
|
raise ServiceError("Token file %s holds no usable token." % path)
|
|
@@ -354,21 +578,38 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
354
578
|
auth = descriptor.get("auth") or {}
|
|
355
579
|
if "tokenFile" not in auth:
|
|
356
580
|
problems.append("auth.tokenFile is required")
|
|
581
|
+
elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
|
|
582
|
+
problems.append("auth.tokenFile must be a local path, never a network share")
|
|
583
|
+
paths = descriptor.get("paths") or {}
|
|
584
|
+
for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
|
|
585
|
+
if not _LOCAL_PATH.match(str(value)):
|
|
586
|
+
problems.append("paths must be local paths, never a network share: %r" % value)
|
|
357
587
|
header = auth.get("header", "Authorization")
|
|
358
588
|
scheme = auth.get("scheme", "Bearer")
|
|
359
589
|
if header != "Authorization" and scheme != "none":
|
|
360
590
|
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
361
591
|
life = descriptor.get("lifecycle") or {}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
592
|
+
manager = life.get("manager")
|
|
593
|
+
if manager not in MANAGERS:
|
|
594
|
+
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
|
|
595
|
+
if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
365
596
|
problems.append("a launchd service declares label and plist")
|
|
597
|
+
if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
|
|
598
|
+
problems.append("lifecycle.plist must be a local path")
|
|
599
|
+
# DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
600
|
+
for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
|
|
601
|
+
if manager == owner and field not in life:
|
|
602
|
+
problems.append("a %s service declares %s" % (owner, field))
|
|
603
|
+
if field in life and manager != owner:
|
|
604
|
+
problems.append("lifecycle.%s belongs to manager %s only" % (field, owner))
|
|
605
|
+
if field in life and not pattern.match(str(life[field])):
|
|
606
|
+
problems.append("lifecycle.%s must match %s" % (field, pattern.pattern))
|
|
366
607
|
if remote:
|
|
367
|
-
if
|
|
608
|
+
if manager != "none":
|
|
368
609
|
problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
|
|
369
|
-
for field in ("label", "plist"):
|
|
610
|
+
for field, of in (("label", "launchd"), ("plist", "launchd"), ("unit", "systemd"), ("task", "Task Scheduler")):
|
|
370
611
|
if field in life:
|
|
371
|
-
problems.append("a remote service has no
|
|
612
|
+
problems.append("a remote service has no %s %s" % (of, field))
|
|
372
613
|
if "update" in (descriptor.get("commands") or {}):
|
|
373
614
|
problems.append("a remote service declares no update command")
|
|
374
615
|
for name, argv in (descriptor.get("commands") or {}).items():
|
|
@@ -376,7 +617,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
376
617
|
problems.append("unknown command %s" % name)
|
|
377
618
|
elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
|
378
619
|
problems.append("command %s must be an argument array" % name)
|
|
379
|
-
elif not
|
|
620
|
+
elif not _LOCAL_PATH.match(argv[0]):
|
|
380
621
|
problems.append("command %s must start with an absolute or ~/ executable" % name)
|
|
381
622
|
return problems
|
|
382
623
|
|
|
@@ -465,11 +706,63 @@ def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] =
|
|
|
465
706
|
return info
|
|
466
707
|
|
|
467
708
|
|
|
709
|
+
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
710
|
+
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def dashboard_extension_problems(value: Any) -> List[str]:
|
|
714
|
+
"""What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
|
|
715
|
+
if not isinstance(value, dict):
|
|
716
|
+
return ["the declaration is not an object"]
|
|
717
|
+
out: List[str] = []
|
|
718
|
+
extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
|
|
719
|
+
if extra:
|
|
720
|
+
out.append("unknown field(s): %s" % ", ".join(extra))
|
|
721
|
+
width = value.get("minWidth")
|
|
722
|
+
if isinstance(width, bool) or not isinstance(width, int):
|
|
723
|
+
out.append("minWidth is required and must be a whole number of CSS pixels")
|
|
724
|
+
elif not 360 <= width <= 1280:
|
|
725
|
+
out.append("minWidth %d is outside 360…1280" % width)
|
|
726
|
+
if "appearance" in value:
|
|
727
|
+
a = value["appearance"]
|
|
728
|
+
if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
|
|
729
|
+
out.append("appearance is a list of distinct fields from scheme, theme")
|
|
730
|
+
if "themes" in value:
|
|
731
|
+
t = value["themes"]
|
|
732
|
+
if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
|
|
733
|
+
or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
|
|
734
|
+
out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
|
|
735
|
+
if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
|
|
736
|
+
out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
|
|
737
|
+
return out
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
|
|
741
|
+
themes: Optional[Sequence[str]] = REFERENCE_THEMES,
|
|
742
|
+
kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
|
|
743
|
+
"""The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
744
|
+
when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
|
|
745
|
+
value: Dict[str, Any] = {"minWidth": min_width}
|
|
746
|
+
if appearance:
|
|
747
|
+
value["appearance"] = list(appearance)
|
|
748
|
+
if themes:
|
|
749
|
+
value["themes"] = list(themes)
|
|
750
|
+
if kit:
|
|
751
|
+
value["kit"] = kit
|
|
752
|
+
problems = dashboard_extension_problems(value)
|
|
753
|
+
if problems:
|
|
754
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
755
|
+
return {DASHBOARD_EXTENSION_KEY: value}
|
|
756
|
+
|
|
757
|
+
|
|
468
758
|
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
469
759
|
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
470
760
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
471
|
-
update_available: Optional[str] = None, pid: Optional[int] = None
|
|
472
|
-
|
|
761
|
+
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
762
|
+
extensions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
763
|
+
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
764
|
+
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
765
|
+
in it must be valid (dashboard_extension builds one)."""
|
|
473
766
|
if status not in STATUSES:
|
|
474
767
|
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
475
768
|
degraded = list(degraded)
|
|
@@ -488,6 +781,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
488
781
|
}
|
|
489
782
|
if summary:
|
|
490
783
|
doc["summary"] = list(summary)[:6]
|
|
784
|
+
if extensions:
|
|
785
|
+
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
786
|
+
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
787
|
+
if problems:
|
|
788
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
789
|
+
doc["extensions"] = dict(extensions)
|
|
491
790
|
return doc
|
|
492
791
|
|
|
493
792
|
|
|
@@ -699,10 +998,10 @@ class JsonlUsageLedger:
|
|
|
699
998
|
line = json.dumps(receipt, separators=(",", ":")) + "\n"
|
|
700
999
|
with self._lock:
|
|
701
1000
|
ensure_private_dir(self.path.parent)
|
|
702
|
-
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
|
|
1001
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND | _O_BINARY, 0o600)
|
|
703
1002
|
try:
|
|
704
1003
|
size = os.fstat(fd).st_size
|
|
705
|
-
if size and
|
|
1004
|
+
if size and _read_at(fd, 1, size - 1) != b"\n":
|
|
706
1005
|
line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
|
|
707
1006
|
os.write(fd, line.encode("utf-8"))
|
|
708
1007
|
finally:
|
|
@@ -779,7 +1078,7 @@ class RotatingLog:
|
|
|
779
1078
|
size = 0
|
|
780
1079
|
if size and size + len(data) > self.max_bytes:
|
|
781
1080
|
self._rotate()
|
|
782
|
-
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
1081
|
+
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
|
|
783
1082
|
try:
|
|
784
1083
|
os.write(fd, data)
|
|
785
1084
|
finally:
|
|
@@ -1114,6 +1413,9 @@ def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
|
1114
1413
|
|
|
1115
1414
|
|
|
1116
1415
|
def _domain() -> str:
|
|
1416
|
+
if sys.platform == "win32":
|
|
1417
|
+
# DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
|
|
1418
|
+
raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
|
|
1117
1419
|
return "gui/%d" % os.getuid()
|
|
1118
1420
|
|
|
1119
1421
|
|
|
@@ -29,10 +29,21 @@ import fabric_service as fs # noqa: E402
|
|
|
29
29
|
import fabric_interop as fi # noqa: E402
|
|
30
30
|
|
|
31
31
|
VERSION = "0.1.0"
|
|
32
|
+
# The dashboard is built from the dashboard kit (fabric-dashboard/0.1, DEC-0036) and declares it
|
|
33
|
+
# in the well-known document only when the kit is there to serve.
|
|
34
|
+
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
35
|
+
KIT_FILES = {"/kit/fabric-dashboard.css": ("fabric-dashboard.css", "text/css; charset=utf-8"),
|
|
36
|
+
"/kit/fabric-dashboard.js": ("fabric-dashboard.js", "text/javascript; charset=utf-8")}
|
|
32
37
|
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
33
38
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
|
|
34
|
-
<
|
|
35
|
-
</head><body><
|
|
39
|
+
<link rel="stylesheet" href="/kit/fabric-dashboard.css"><script src="/kit/fabric-dashboard.js" defer></script>
|
|
40
|
+
</head><body><div class="fd-chrome"><header class="fd-header"><p class="fd-title">{name}</p>
|
|
41
|
+
<span class="fd-chip fd-s-ok"><span class="fd-dot"></span>Status: {status}</span></header>
|
|
42
|
+
<nav class="fd-nav" aria-label="Sections"><ul class="fd-nav-list"><li><a href="#events" aria-current="page">Events</a></li>
|
|
43
|
+
<li><a href="#about">About</a></li></ul></nav></div>
|
|
44
|
+
<main class="fd-page"><section id="events"><h2>Events</h2><ul>{rows}</ul></section>
|
|
45
|
+
<section id="about"><h2>About</h2><p class="fd-hint">A sample fabric-service/0.1 service built on the kit.</p></section></main>
|
|
46
|
+
</body></html>"""
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def _schema(name: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -130,6 +141,7 @@ class Service:
|
|
|
130
141
|
surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
|
|
131
142
|
"mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]},
|
|
132
143
|
"usage": {"path": fs.USAGE_PATH}},
|
|
144
|
+
extensions=fs.dashboard_extension() if (KIT_DIR / "fabric-dashboard.css").is_file() else None,
|
|
133
145
|
)
|
|
134
146
|
|
|
135
147
|
|
|
@@ -148,7 +160,7 @@ def make_handler(svc: Service):
|
|
|
148
160
|
self.send_response(status)
|
|
149
161
|
self.send_header("Cache-Control", "no-store")
|
|
150
162
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
151
|
-
self.send_header("Content-Security-Policy", "default-src 'none'; style-src '
|
|
163
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; frame-ancestors 'self'")
|
|
152
164
|
if body is not None:
|
|
153
165
|
self.send_header("Content-Type", content_type)
|
|
154
166
|
self.send_header("Content-Length", str(len(payload)))
|
|
@@ -203,6 +215,12 @@ def make_handler(svc: Service):
|
|
|
203
215
|
if not cookie:
|
|
204
216
|
return self._send(403, "This sign-in link was already used or has expired.", content_type="text/plain")
|
|
205
217
|
return self._send(302, None, {"Location": "/", "Set-Cookie": fs.session_cookie_header(cookie)})
|
|
218
|
+
if url.path in KIT_FILES:
|
|
219
|
+
name, kind = KIT_FILES[url.path]
|
|
220
|
+
try:
|
|
221
|
+
return self._send(200, (KIT_DIR / name).read_text(encoding="utf-8"), content_type=kind)
|
|
222
|
+
except OSError:
|
|
223
|
+
return self._send(404, {"error": "Not found."})
|
|
206
224
|
if url.path == "/":
|
|
207
225
|
if not self._session_ok():
|
|
208
226
|
return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.9.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "a83bec6b47776af688402c18efec507143061f68"
|
|
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 `a83bec6b47776af688402c18efec507143061f68` 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,42 @@ 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
|
+
In a group shared with other bots the channel follows OC-14…OC-17 (DEC-0035): it stays silent
|
|
183
|
+
unless addressed — `/command@ThisBot`, a reply to its own message or a press of its own button; a
|
|
184
|
+
bare `/command` in a group is not addressed — and a channel nobody receives on is `degraded`
|
|
185
|
+
(`receiver-missing`). Say so in the proposal when the operator means to bind a shared group.
|
|
186
|
+
|
|
187
|
+
Record the answer — `accepted`, `declined` or `later` — in the agent's state and in the
|
|
188
|
+
report, and do not ask again; an agent that serves `surfaces.operatorChannel` also shows it as
|
|
189
|
+
`proposal` in its status document. Creating the bot, storing the token and enabling the channel are the
|
|
190
|
+
operator's action: never create the bot, ask for or store the token's value, or enable the
|
|
191
|
+
channel yourself. The default pin carries DEC-0034 and DEC-0035: a served status document is checked against
|
|
192
|
+
the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime rules have no
|
|
193
|
+
probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
|
|
194
|
+
events skips this step.
|
|
195
|
+
|
|
167
196
|
## Completion format
|
|
168
197
|
|
|
169
198
|
Report: capability, consumer, profile with the step-0 grill answers; the knowledge pack
|
|
170
199
|
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.
|
|
200
|
+
table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`; remaining placeholders; the
|
|
201
|
+
operator channel answer (step 6); and the exact next command. Never summarize the outcome as
|
|
202
|
+
"Fabric-compatible" unless every required admission gate passed against the real provider.
|
|
174
203
|
|
|
175
204
|
## Dashboard handoff — also for consumers
|
|
176
205
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "passioncode",
|
|
4
4
|
"displayName": "PassionCode.ai",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.33",
|
|
6
6
|
"description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|