@passioncode-ai/passioncode 0.2.37 → 0.2.39
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 +16 -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/skills/adapting-projects-to-fabric/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +4 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -14
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +38 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +9 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +99 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +6 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +47 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +325 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +246 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +45 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +45 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +302 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +154 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +45 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +202 -18
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +41 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
|
|
2
|
-
network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report,
|
|
3
|
-
whether two repository URLs name one repository
|
|
2
|
+
network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report,
|
|
3
|
+
whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
|
|
4
|
+
opens its pages in a browser (FAA-118). ``check_service.py`` runs them against a live service.
|
|
4
5
|
Standard library only, Python 3.9+."""
|
|
5
6
|
from __future__ import annotations
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
17
18
|
|
|
18
19
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
20
|
import fabric_service as fs # noqa: E402 - beside this file
|
|
21
|
+
import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def plist_problems(plist: dict, label: object, token: object = None) -> list:
|
|
@@ -291,3 +293,324 @@ def same_repository(a: str, b: str) -> bool:
|
|
|
291
293
|
u = re.sub(r"^[^@/]+@", "", u)
|
|
292
294
|
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
293
295
|
return bool(a and b) and norm(a) == norm(b)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# actions (fabric-actions/0.1, DEC-0121, FAC-SEM-142) ----------------------------------------
|
|
299
|
+
_ACTIONS_KEY_PATH = "/extensions/" + fa.ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _actions_place(instance_path: str) -> str:
|
|
303
|
+
"""A finding's place inside the declaration (`/actions/1/id`), or the declaration itself."""
|
|
304
|
+
inner = instance_path[len(_ACTIONS_KEY_PATH):] if instance_path.startswith(_ACTIONS_KEY_PATH) else instance_path
|
|
305
|
+
return inner or "extensions/actions/0.1"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def well_known_actions_verdict(wk: Any) -> Tuple[str, str]:
|
|
309
|
+
"""`well-known.actions`: a declared extensions/actions/0.1 is FAC-SEM-142's (the kit's actions_extension_rules, the
|
|
310
|
+
contract's reader): its schema's, each id once, humanStep exactly when gate is human, every tool in a present
|
|
311
|
+
surfaces.mcp.capabilities. No declaration is NOT_RUN: dashboard.actions-declared says what that means."""
|
|
312
|
+
view = fa.actions_view(wk)
|
|
313
|
+
if not view["declared"]:
|
|
314
|
+
return "NOT_RUN", "no extensions/actions/0.1 declaration (DEC-0121 AP-1)"
|
|
315
|
+
findings = fa.actions_extension_rules(wk)
|
|
316
|
+
if findings:
|
|
317
|
+
return "FAIL", "; ".join("FAC-SEM-142 %s: %s" % (_actions_place(f["instancePath"]), f["message"]) for f in findings[:5])
|
|
318
|
+
gates: Dict[str, int] = {}
|
|
319
|
+
for action in view["actions"]:
|
|
320
|
+
gates[action["gate"]] = gates.get(action["gate"], 0) + 1
|
|
321
|
+
return "PASS", "%d action(s) (%s), FAC-SEM-142 holds" % (
|
|
322
|
+
len(view["actions"]), ", ".join("%d %s" % (n, g) for g, n in sorted(gates.items())))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def action_tools_verdict(wk: Any, tools: Optional[Dict[str, Any]], why_not: str = "tools/list was not read",
|
|
326
|
+
no_surface: bool = False, remote: bool = False) -> Tuple[str, str]:
|
|
327
|
+
"""`interop.action-tools`: every declared action's tool is served by tools/list, the live surface (FAC-SEM-142
|
|
328
|
+
checks only the list the document names). `tools` None means no listing was read; `why_not` says why. A local
|
|
329
|
+
service that declares actions and has no MCP surface fails: every action has a tool on its agent surface
|
|
330
|
+
(DEC-0121 AP-1), and a local service's is MCP. A remote agent's surface may be A2A or MCP behind a gateway (the
|
|
331
|
+
contract's surfaces table), so there it is NOT_RUN. This goes beyond FAC-SEM-142, which reads only a present list."""
|
|
332
|
+
view = fa.actions_view(wk)
|
|
333
|
+
if not view["declared"]:
|
|
334
|
+
return "NOT_RUN", "no extensions/actions/0.1 declaration"
|
|
335
|
+
if not view["valid"]:
|
|
336
|
+
return "NOT_RUN", "the actions declaration is not readable (well-known.actions): a reader treats it as not declared"
|
|
337
|
+
if no_surface and remote:
|
|
338
|
+
return "NOT_RUN", ("%d action(s) declared and no MCP surface: a remote agent's twin may be on A2A or behind a "
|
|
339
|
+
"gateway, which this probe does not read" % len(view["actions"]))
|
|
340
|
+
if no_surface:
|
|
341
|
+
return "FAIL", ("%d action(s) declared but the service has no MCP surface: buttons without an agent twin "
|
|
342
|
+
"(DEC-0121 AP-1)" % len(view["actions"]))
|
|
343
|
+
if tools is None:
|
|
344
|
+
return "NOT_RUN", why_not
|
|
345
|
+
missing = ["%s (%s)" % (a["tool"], a["id"]) for a in view["actions"] if a["tool"] not in tools]
|
|
346
|
+
if missing:
|
|
347
|
+
return "FAIL", ("tools/list does not serve %s: a button without an agent twin (DEC-0121 AP-1)" % ", ".join(missing))
|
|
348
|
+
return "PASS", "%d declared action(s), every tool served by tools/list" % len(view["actions"])
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def actions_declared_verdict(wk: Any) -> Tuple[str, str]:
|
|
352
|
+
"""`dashboard.actions-declared` (decision D10): a dashboard whose declaration FAC-SEM-142 accepts passes, one it
|
|
353
|
+
finds (unreadable, a repeated id, a step that disagrees with the gate, a tool the surface does not list) fails,
|
|
354
|
+
and one with none is NOT_RUN, never FAIL: a read-only dashboard cannot declare an empty list
|
|
355
|
+
(minItems 1), so absence proves no page-only button. Fabric Dashboards reports it (CT-126)."""
|
|
356
|
+
surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
|
|
357
|
+
if not (isinstance(surfaces, dict) and surfaces.get("dashboard")):
|
|
358
|
+
return "NOT_RUN", "no surfaces.dashboard"
|
|
359
|
+
view = fa.actions_view(wk)
|
|
360
|
+
if not view["declared"]:
|
|
361
|
+
return "NOT_RUN", ("a dashboard with no extensions/actions/0.1 declaration: a read-only dashboard declares none "
|
|
362
|
+
"(the list needs minItems 1), so its absence proves no page-only button; a dashboard with a "
|
|
363
|
+
"button declares it and its agent twin (DEC-0121 AP-1). Fabric Dashboards reports it (CT-126)")
|
|
364
|
+
if not view["valid"]:
|
|
365
|
+
return "FAIL", "the dashboard's actions declaration is not readable, so a host treats it as not declared (well-known.actions)"
|
|
366
|
+
findings = fa.actions_extension_rules(wk)
|
|
367
|
+
if findings:
|
|
368
|
+
return "FAIL", "the dashboard's actions declaration breaks FAC-SEM-142 in %d place(s) (well-known.actions)" % len(findings)
|
|
369
|
+
return "PASS", "the dashboard declares %d action(s) with their tools (DEC-0121 AP-1)" % len(view["actions"])
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# dashboard.opens-in-host (FAA-118; the operator rule of 2026-10-24; decision D13) ----------------------------------
|
|
373
|
+
# A service's own pages open only through Fabric Dashboards: the `open_link` of the fabric-dashboards MCP `link` tool,
|
|
374
|
+
# opened with the host's `open` and `fallback=never`; a browser only on a confirmed `not_installed` host. The rule reads
|
|
375
|
+
# the service's checkout, tracked text files only (`git grep -I`, no untracked file, no binary), within a time budget,
|
|
376
|
+
# and reports a line that opens a browser at the service's pages. The matcher, kept precise on purpose:
|
|
377
|
+
#
|
|
378
|
+
# 1. An opener (`_OPENERS`): macOS `open -a <browser>` / `open -b <browser bundle id>` / `open <http(s) URL>` /
|
|
379
|
+
# `open $<...url|link|origin|dashboard|page...>`; `xdg-open`, `gio open`, `sensible-browser`, `x-www-browser`,
|
|
380
|
+
# `wslview`; Windows `start [""] <URL>`, `explorer <URL>`, `url.dll,FileProtocolHandler`; Python
|
|
381
|
+
# `webbrowser.open|open_new|open_new_tab|get(`; Electron `openExternal(`; a process call whose argv starts with one
|
|
382
|
+
# of those programs (`subprocess.run(["open", ...])`, `spawn("xdg-open", ...)`); and in a JavaScript or TypeScript
|
|
383
|
+
# file that imports the npm package `open`, a call of the name it was bound to (`open(...)`, never `x.open(...)`).
|
|
384
|
+
# Python's builtin `open(` and a page's `window.open(` are files and tabs, never an opener.
|
|
385
|
+
# 2. Aimed at the service's pages: in code, the opener's line or one of the 5 lines before it (where a URL is built)
|
|
386
|
+
# names a loopback host (127.0.0.1, localhost, [::1], 0.0.0.0), the service's port as a whole number, its origin's
|
|
387
|
+
# host, its dashboard path (when it is not just "/") or its login path; or the opener's line names an identifier
|
|
388
|
+
# built on "dashboard" (`dashboard_url`, `openDashboard`; never the host app's name "Fabric Dashboards"). In prose
|
|
389
|
+
# the aim is on the opener's own line. An opener with no such aim (a docs site, a report file) is not this rule's.
|
|
390
|
+
# 3. In prose (Markdown and the like) only code counts: a fenced block, or an inline code span (`open -a Safari ...`);
|
|
391
|
+
# "then open `http://localhost:3000`" is a sentence for a person. A prose line that forbids it (never, not, avoid,
|
|
392
|
+
# instead of, ...) is not a finding either.
|
|
393
|
+
# 4. Not a finding: the host's own route (`fabric-dashboards://`, the "Fabric Dashboards" app, `open_link`,
|
|
394
|
+
# `native_link` on the line); the allowed fallback (`not_installed` on the line or the 5 before, or the gate's
|
|
395
|
+
# `browser_link` on the line); a comment line in code; and test, spec and fixture files, which plant raw opens on
|
|
396
|
+
# purpose.
|
|
397
|
+
_BROWSERS = (r"(?:Safari(?: Technology Preview)?|Google Chrome(?: Canary| Dev| Beta)?|Chrome|Chromium|"
|
|
398
|
+
r"Firefox(?: Developer Edition| Nightly)?|Arc|Dia|Brave Browser|Brave|Microsoft Edge|Opera|Vivaldi|Orion|Zen)")
|
|
399
|
+
_BUNDLES = (r"(?:com\.apple\.Safari\w*|com\.google\.Chrome\w*|org\.chromium\.Chromium|org\.mozilla\.firefox\w*|"
|
|
400
|
+
r"company\.thebrowser\.\w+|com\.brave\.Browser\w*|com\.microsoft\.edgemac\w*|com\.operasoftware\.Opera\w*|"
|
|
401
|
+
r"com\.vivaldi\.Vivaldi|com\.kagi\.kagimacOS)")
|
|
402
|
+
_PROGRAMS = r"(?:open|xdg-open|wslview|sensible-browser|x-www-browser|gio|start|explorer(?:\.exe)?)"
|
|
403
|
+
#: Where a shell command starts: a line, a separator, `$(`, a Makefile's `@`, `then`, `do`, `else` or cmd's `/c`, or
|
|
404
|
+
#: right after an opening quote or backtick (a string a process call runs). `playwright-cli open <url>` is that tool's
|
|
405
|
+
#: own subcommand, and `--proxy "http://..." open` an argument after a closing quote.
|
|
406
|
+
_CMD = r"(?:(?:^|[;&|({@]|\$\(|\bthen\b|\bdo\b|\belse\b|/[cC]\b)\s*|[`'\"])"
|
|
407
|
+
_OPENERS = re.compile("|".join((
|
|
408
|
+
_CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-a\s+[\"']?" + _BROWSERS + r"(?![\w-])",
|
|
409
|
+
_CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-b\s+[\"']?" + _BUNDLES,
|
|
410
|
+
_CMD + r"open\s+(?:-[gnF]\s+)*[\"'`]?(?:https?://|\$\{?\w*(?i:url|link|origin|dashboard|page)\w*)",
|
|
411
|
+
r"(?<![\w.$/-])(?:xdg-open|gio\s+open|sensible-browser|x-www-browser|wslview)(?![\w-])",
|
|
412
|
+
_CMD + r"start\s+(?:\"\"\s+)?[\"']?https?://",
|
|
413
|
+
_CMD + r"explorer(?:\.exe)?\s+[\"']?https?://",
|
|
414
|
+
r"url\.dll,\s*FileProtocolHandler",
|
|
415
|
+
r"\bwebbrowser\.(?:open|open_new|open_new_tab|get)\(", # a call, never "webbrowser.open (the default)" in prose
|
|
416
|
+
r"\bopenExternal\(",
|
|
417
|
+
r"(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|Popen|run|call|check_call|check_output)\s*\(\s*\[?\s*"
|
|
418
|
+
r"[\"'`]" + _PROGRAMS + r"[\"'`]\s*[,)\]]",
|
|
419
|
+
)))
|
|
420
|
+
_OPEN_PACKAGE = re.compile(r"""import\s+(\w+)\s+from\s+["']open["']|(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*["']open["']\s*\)""")
|
|
421
|
+
_JS_FILES = (".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
|
|
422
|
+
_PROSE_FILES = (".md", ".markdown", ".mdx", ".rst", ".txt", ".adoc", ".org")
|
|
423
|
+
_LOOPBACK = re.compile(r"(?:127\.0\.0\.1|\blocalhost\b|\[::1\]|0\.0\.0\.0)(?::(\d{1,5}))?")
|
|
424
|
+
_HOST_ROUTE = re.compile(r"fabric-dashboards://|Fabric Dashboards|\b(?:open_link|openLink|native_link|nativeLink)\b")
|
|
425
|
+
_FALLBACK = re.compile(r"\bnot[_-]installed\b|\bnotInstalled\b|\bNOT_INSTALLED\b")
|
|
426
|
+
_GATE_LINK = re.compile(r"\b(?:browser_link|browserLink)\b")
|
|
427
|
+
_NEGATION = re.compile(r"(?i)\b(?:never|not|don't|doesn't|avoid|instead of|forbidden|refused?|wrong|no longer|nonconformant)\b")
|
|
428
|
+
_COMMENT = re.compile(r"\s*(?:#(?!!)|//|/\*|\*|<!--|REM\s|::)")
|
|
429
|
+
_TEST_PATH = re.compile(r"(?:^|/)(?:tests?|__tests__|specs?|fixtures|testdata|e2e)/|(?:^|/)(?:test_[^/]*\.py|[^/]*_test\.\w+|"
|
|
430
|
+
r"[^/]*\.(?:test|spec)\.\w+|conftest\.py)\Z")
|
|
431
|
+
_WINDOW = 5 # lines before an opener that may hold its aim or the not_installed branch
|
|
432
|
+
OPENS_IN_HOST_BUDGET = 3.0 # seconds for git and reading, as the session-start notice budgets its own git calls
|
|
433
|
+
_MAX_CANDIDATES = 2000
|
|
434
|
+
_MAX_FILE_BYTES = 1 << 20
|
|
435
|
+
_COARSE = (r"open[[:space:]]+-[ab][[:space:]]|open[[:space:]]+[\"'`]?(https?://|\$)|xdg-open|gio[[:space:]]+open|"
|
|
436
|
+
r"sensible-browser|x-www-browser|wslview|start[[:space:]]+(\"\"[[:space:]]+)?[\"']?https?://|"
|
|
437
|
+
r"explorer(\.exe)?[[:space:]]+[\"']?https?://|FileProtocolHandler|webbrowser\.|openExternal|"
|
|
438
|
+
r"[\"'`](open|xdg-open|wslview|start|explorer)[\"'`]")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _aims(lines: List[str], port: int, host: str, dashboard_path: str) -> bool:
|
|
442
|
+
text = "\n".join(lines)
|
|
443
|
+
if "/fabric/v1/login" in text:
|
|
444
|
+
return True
|
|
445
|
+
# A loopback address is this service's only with its port or with none: another port is another local tool.
|
|
446
|
+
if any(m.group(1) is None or not port or int(m.group(1)) == port for m in _LOOPBACK.finditer(text)):
|
|
447
|
+
return True
|
|
448
|
+
if port and re.search(r"(?<!\d)%d(?!\d)" % port, text):
|
|
449
|
+
return True
|
|
450
|
+
if host and host in text:
|
|
451
|
+
return True
|
|
452
|
+
return bool(dashboard_path and dashboard_path != "/" and re.search(re.escape(dashboard_path) + r"(?![\w-])", text))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
#: Text before an opener that makes it a message for a person: `echo "... open http://..."`, print, a log call.
|
|
456
|
+
_MESSAGE = re.compile(r"\b(?:echo|printf|print|puts|say)\b|\bconsole\.\w+\s*\(|\blog(?:ger)?\.\w+\s*\(|\blog\s*\(")
|
|
457
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
458
|
+
_FENCE = re.compile(r"\s*(?:```|~~~)")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _names_dashboard(line: str) -> bool:
|
|
462
|
+
"""An identifier built on "dashboard" (dashboard_url, openDashboard), never the word alone or the host's name."""
|
|
463
|
+
return any("dashboard" in word.lower() and word.lower() not in ("dashboard", "dashboards")
|
|
464
|
+
for word in _IDENTIFIER.findall(line.split("://", 1)[0]))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _prose_code(lines: List[str]) -> List[str]:
|
|
468
|
+
"""Each line of a prose file reduced to its code: whole inside a fenced block, else its inline code spans."""
|
|
469
|
+
out, fenced = [], False
|
|
470
|
+
for line in lines:
|
|
471
|
+
if _FENCE.match(line):
|
|
472
|
+
fenced = not fenced
|
|
473
|
+
out.append("")
|
|
474
|
+
elif fenced:
|
|
475
|
+
out.append(line)
|
|
476
|
+
else:
|
|
477
|
+
out.append(" ; ".join(re.findall(r"`([^`]+)`", line))) # each span its own command
|
|
478
|
+
return out
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def opener_findings(path: str, text: str, port: int, origin: Optional[str], dashboard_path: Optional[str]) -> List[Tuple[int, str]]:
|
|
482
|
+
"""The lines of one file (repository-relative `path`, its `text`) that open a browser at the service's pages, as
|
|
483
|
+
(line number, line); the matcher is described above. Never raises on what it reads."""
|
|
484
|
+
path = (path or "").replace("\\", "/")
|
|
485
|
+
if not isinstance(text, str) or _TEST_PATH.search(path):
|
|
486
|
+
return []
|
|
487
|
+
prose = path.lower().endswith(_PROSE_FILES)
|
|
488
|
+
calls = None
|
|
489
|
+
if path.lower().endswith(_JS_FILES):
|
|
490
|
+
names = [a or b for a, b in _OPEN_PACKAGE.findall(text)]
|
|
491
|
+
if names:
|
|
492
|
+
calls = re.compile(r"(?<![\w.$])(?:%s)\s*\(" % "|".join(re.escape(n) for n in names))
|
|
493
|
+
host = ""
|
|
494
|
+
if isinstance(origin, str) and "://" in origin:
|
|
495
|
+
host = origin.split("://", 1)[1].split("/", 1)[0].rsplit("@", 1)[-1]
|
|
496
|
+
if _LOOPBACK.fullmatch(host.rsplit(":", 1)[0]):
|
|
497
|
+
host = "" # a loopback origin is covered by the loopback hosts and the port
|
|
498
|
+
original = text.splitlines()
|
|
499
|
+
lines = _prose_code(original) if prose else original
|
|
500
|
+
out: List[Tuple[int, str]] = []
|
|
501
|
+
for index, line in enumerate(lines):
|
|
502
|
+
match = _OPENERS.search(line) or (calls.search(line) if calls is not None and not _OPEN_PACKAGE.search(line) else None)
|
|
503
|
+
if match is None or _MESSAGE.search(line[:match.start()]):
|
|
504
|
+
continue
|
|
505
|
+
if _HOST_ROUTE.search(line) or _GATE_LINK.search(line):
|
|
506
|
+
continue
|
|
507
|
+
if (prose and _NEGATION.search(original[index])) or _COMMENT.match(line):
|
|
508
|
+
continue
|
|
509
|
+
window = lines[max(0, index - _WINDOW):index + 1]
|
|
510
|
+
if any(_FALLBACK.search(w) for w in window):
|
|
511
|
+
continue
|
|
512
|
+
aim = [line] if prose else window
|
|
513
|
+
if _aims(aim, port, host, str(dashboard_path or "")) or _names_dashboard(line):
|
|
514
|
+
out.append((index + 1, original[index].strip()))
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def opens_in_host_verdict(repo: Path, port: int, origin: Optional[str], dashboard_path: Optional[str],
|
|
519
|
+
budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
|
|
520
|
+
"""`dashboard.opens-in-host` over one checkout: FAIL with the places a tracked text file opens a browser at the
|
|
521
|
+
service's pages, PASS when none does, NOT_RUN when the checkout or git cannot be read or the budget runs out."""
|
|
522
|
+
until = time.monotonic() + max(0.0, budget)
|
|
523
|
+
if not shutil.which("git"):
|
|
524
|
+
return "NOT_RUN", "git is not installed"
|
|
525
|
+
|
|
526
|
+
def left() -> float:
|
|
527
|
+
return until - time.monotonic()
|
|
528
|
+
|
|
529
|
+
if left() <= 0:
|
|
530
|
+
return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
|
|
531
|
+
try:
|
|
532
|
+
top = subprocess.run(["git", "-C", str(repo), "rev-parse", "--show-toplevel"], capture_output=True, text=True,
|
|
533
|
+
timeout=max(0.05, left()))
|
|
534
|
+
if top.returncode != 0 or not top.stdout.strip():
|
|
535
|
+
return "NOT_RUN", "%s is not a Git checkout" % repo
|
|
536
|
+
root = Path(top.stdout.strip())
|
|
537
|
+
if left() <= 0:
|
|
538
|
+
return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
|
|
539
|
+
listed = subprocess.run(["git", "-C", str(root), "grep", "-I", "-l", "-z", "-E", _COARSE], capture_output=True,
|
|
540
|
+
timeout=max(0.05, left()))
|
|
541
|
+
except subprocess.TimeoutExpired:
|
|
542
|
+
return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
|
|
543
|
+
except OSError as exc:
|
|
544
|
+
return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
|
|
545
|
+
if listed.returncode not in (0, 1): # 1: nothing matched
|
|
546
|
+
return "NOT_RUN", "git grep could not read %s" % root
|
|
547
|
+
candidates = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
|
|
548
|
+
if len(candidates) > _MAX_CANDIDATES:
|
|
549
|
+
return "NOT_RUN", "%d candidate files in %s, more than the %d this rule reads" % (len(candidates), root, _MAX_CANDIDATES)
|
|
550
|
+
found: List[str] = []
|
|
551
|
+
for name in candidates:
|
|
552
|
+
if left() <= 0:
|
|
553
|
+
return "NOT_RUN", "the %.1f s budget ran out after reading part of %s" % (budget, root)
|
|
554
|
+
try:
|
|
555
|
+
with open(root / name, "rb") as handle:
|
|
556
|
+
text = handle.read(_MAX_FILE_BYTES).decode("utf-8", "replace")
|
|
557
|
+
except OSError:
|
|
558
|
+
continue
|
|
559
|
+
found += ["%s:%d %s" % (name, line, snippet[:100]) for line, snippet in opener_findings(name, text, port, origin, dashboard_path)]
|
|
560
|
+
if found:
|
|
561
|
+
return "FAIL", ("%s. Open the service's pages through Fabric Dashboards: the open_link of the fabric-dashboards MCP "
|
|
562
|
+
"link tool, opened with fallback=never; a browser only on a confirmed not_installed host "
|
|
563
|
+
"(operator rule of 2026-10-24)" % "; ".join(found[:5] + (["and %d more" % (len(found) - 5)] if len(found) > 5 else [])))
|
|
564
|
+
return "PASS", ("%d tracked text file(s) in %s name a possible opener; none opens a browser at the service's pages: "
|
|
565
|
+
"they open through Fabric Dashboards" % (len(candidates), root))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _git_out(cwd: Path, *args: str) -> Optional[str]:
|
|
569
|
+
try:
|
|
570
|
+
done = subprocess.run(["git", "-C", str(cwd)] + list(args), capture_output=True, text=True, timeout=2.0)
|
|
571
|
+
except (OSError, subprocess.SubprocessError):
|
|
572
|
+
return None
|
|
573
|
+
return done.stdout.strip() if done.returncode == 0 and done.stdout.strip() else None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Path], str]:
|
|
577
|
+
"""The service's checkout: `repo` (the probe's --repo) when given, else the checkout holding one of the
|
|
578
|
+
descriptor's paths (its commands, its fabricManifest, its launchd job's program and working directory) whose origin
|
|
579
|
+
is the descriptor's `source.repository`. (None, why) when there is none."""
|
|
580
|
+
if repo:
|
|
581
|
+
top = _git_out(Path(repo).expanduser(), "rev-parse", "--show-toplevel")
|
|
582
|
+
return (Path(top), "--repo") if top else (None, "--repo %s is not a Git checkout" % repo)
|
|
583
|
+
d = descriptor if isinstance(descriptor, dict) else {}
|
|
584
|
+
own = d.get("source")
|
|
585
|
+
source = own.get("repository") if isinstance(own, dict) else None
|
|
586
|
+
if not isinstance(source, str) or not source:
|
|
587
|
+
return None, "the descriptor names no source.repository to find the service's checkout by; pass --repo"
|
|
588
|
+
places: List[str] = []
|
|
589
|
+
commands = d.get("commands")
|
|
590
|
+
for argv in (commands.values() if isinstance(commands, dict) else []):
|
|
591
|
+
places += [a for a in argv if isinstance(a, str)] if isinstance(argv, list) else []
|
|
592
|
+
if isinstance(d.get("fabricManifest"), str):
|
|
593
|
+
places.append(d["fabricManifest"])
|
|
594
|
+
life = d.get("lifecycle") if isinstance(d.get("lifecycle"), dict) else {}
|
|
595
|
+
if isinstance(life.get("plist"), str):
|
|
596
|
+
try:
|
|
597
|
+
import plistlib
|
|
598
|
+
plist = plistlib.loads(Path(life["plist"]).expanduser().read_bytes())
|
|
599
|
+
places += [a for a in (plist.get("ProgramArguments") or []) if isinstance(a, str)]
|
|
600
|
+
places += [plist["WorkingDirectory"]] if isinstance(plist.get("WorkingDirectory"), str) else []
|
|
601
|
+
except (OSError, ValueError, AttributeError, TypeError, plistlib.InvalidFileException):
|
|
602
|
+
pass
|
|
603
|
+
seen = set()
|
|
604
|
+
for place in places:
|
|
605
|
+
if not (place.startswith(("/", "~")) or re.match(r"[A-Za-z]:[\\/]", place)):
|
|
606
|
+
continue
|
|
607
|
+
start = Path(os.path.expanduser(place))
|
|
608
|
+
while not start.is_dir() and start != start.parent:
|
|
609
|
+
start = start.parent
|
|
610
|
+
top = _git_out(start, "rev-parse", "--show-toplevel")
|
|
611
|
+
if not top or top in seen:
|
|
612
|
+
continue
|
|
613
|
+
seen.add(top)
|
|
614
|
+
if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
|
|
615
|
+
return Path(top), "the checkout of %s that holds %s" % (source, place)
|
|
616
|
+
return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % source
|
|
@@ -292,7 +292,8 @@ def services_of(repo: Path, directory: Optional[Path] = None, until: Optional[fl
|
|
|
292
292
|
d = json.loads(path.read_text(encoding="utf-8"))
|
|
293
293
|
except (OSError, ValueError):
|
|
294
294
|
continue
|
|
295
|
-
|
|
295
|
+
own = d.get("source") if isinstance(d, dict) else None
|
|
296
|
+
source = str(own.get("repository") or "") if isinstance(own, dict) else ""
|
|
296
297
|
if same_repository(source, origin):
|
|
297
298
|
found.append("%s.%s" % (d.get("id"), d.get("instance", "default")))
|
|
298
299
|
return found
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Every action a person has, its agent tool and who decides it: fabric-actions/0.1, the extension keyed
|
|
2
|
+
// extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the Node service kit (layer L3); the twin of fabric_actions.py.
|
|
3
|
+
//
|
|
4
|
+
// - actionDecision(inputs): whether a service takes a call of one declared action and as whose act it records it,
|
|
5
|
+
// exactly as the contract's actionDecision answers (AP-2, AP-4; DL-17, DL-18, DL-25). A `fault` is the service's
|
|
6
|
+
// own and is never sent to a caller.
|
|
7
|
+
// - decisionShape(value): a decision as decision.schema.json reads it, `at` read strictly (the contract's strictTime).
|
|
8
|
+
// - actionsView(wellKnown): the host reader of the declaration, as the contract's actionsView.
|
|
9
|
+
// - actionsExtensionRules(wellKnown), actionRules(kind, value): FAC-SEM-142.
|
|
10
|
+
// - actionsExtension(actions, { capabilities }): the `extensions` entry a service declares; it refuses a declaration
|
|
11
|
+
// FAC-SEM-142 would find, so a service never publishes one.
|
|
12
|
+
// - operatorDecision({action, instruction, at, target}): the decision an agent relays in _meta.decision (AP-2, AP-3),
|
|
13
|
+
// refused when decision.schema.json would refuse it.
|
|
14
|
+
// - actionCaller(tier, authenticated): the caller a service records (AP-2): the identity it authenticated, else
|
|
15
|
+
// local:T0 / local:T1 for a caller its local floor authenticated, never a name the call carries.
|
|
16
|
+
//
|
|
17
|
+
// The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
|
|
18
|
+
// patterns over the whole string, as the contract's validator reads them. Members are an object's own members (a JSON
|
|
19
|
+
// text has no other kind; an inherited member is not read). Never throws on what it reads. Node 20+, no dependency.
|
|
20
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
21
|
+
import { isObject, jsonEqual } from './fabric-jsvalue.mjs';
|
|
22
|
+
import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
|
|
23
|
+
|
|
24
|
+
export const ACTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1';
|
|
25
|
+
/** The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets. */
|
|
26
|
+
export const OPERATOR_DECISION_REQUIRED = 'operator-decision-required';
|
|
27
|
+
export const DECISION_INVALID = 'decision-invalid';
|
|
28
|
+
export const HUMAN_STEP_REQUIRED = 'human-step-required';
|
|
29
|
+
export const APPROVAL_REQUIRED = 'approval-required';
|
|
30
|
+
export const GATES = Object.freeze(['none', 'relayed', 'human']);
|
|
31
|
+
export const HUMAN_STEPS = Object.freeze(['sign-in', 'consent', 'payment', 'secret', 'spend-above-limit', 'passkey']);
|
|
32
|
+
export const MAX_ACTIONS = 128;
|
|
33
|
+
/** How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS). */
|
|
34
|
+
export const CLOCK_SKEW_SECONDS = 60;
|
|
35
|
+
/** How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051). */
|
|
36
|
+
export const DECISION_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
37
|
+
|
|
38
|
+
const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/u; // interop-common.schema.json#/$defs/requestKey
|
|
39
|
+
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/u; // common.schema.json#/$defs/capabilityName
|
|
40
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
41
|
+
const STEP_URL = /^(https:\/\/|\/)/;
|
|
42
|
+
const ACTION_MEMBERS = new Set(['id', 'title', 'tool', 'gate', 'humanStep']);
|
|
43
|
+
const DECISION_MEMBERS = new Set(['by', 'via', 'action', 'target', 'instruction', 'at']);
|
|
44
|
+
const KEY_PATH = `/extensions/${ACTIONS_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
45
|
+
|
|
46
|
+
/** An own member that is not undefined, as the validator reads a member as present. */
|
|
47
|
+
const has = (value, key) => Object.hasOwn(value, key) && value[key] !== undefined;
|
|
48
|
+
/** An own member, or undefined. */
|
|
49
|
+
const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
50
|
+
/** The number of code points, as the validator counts a string's length (a surrogate pair is one). */
|
|
51
|
+
const codePoints = (value) => {
|
|
52
|
+
let count = 0;
|
|
53
|
+
for (const _ of value) count += 1;
|
|
54
|
+
return count;
|
|
55
|
+
};
|
|
56
|
+
const text = (value, low, high) => typeof value === 'string' && value.length <= high * 2 && codePoints(value) >= low && codePoints(value) <= high;
|
|
57
|
+
const matches = (pattern, value, high) => typeof value === 'string' && value.length <= high && pattern.test(value);
|
|
58
|
+
const nonEmpty = (value) => typeof value === 'string' && value.length > 0;
|
|
59
|
+
const oneOf = (value, choices) => typeof value === 'string' && choices.includes(value);
|
|
60
|
+
|
|
61
|
+
/** service-actions-extension.schema.json#/$defs/action: the errors at `at`, empty for a valid entry. */
|
|
62
|
+
function actionErrors(value, at) {
|
|
63
|
+
if (!isObject(value)) return [`${at} type`];
|
|
64
|
+
const errors = Object.keys(value).filter((key) => !ACTION_MEMBERS.has(key)).map((key) => `${at}/${key} additionalProperties`);
|
|
65
|
+
for (const key of ['id', 'title', 'tool', 'gate']) if (!has(value, key)) errors.push(`${at} required ${key}`);
|
|
66
|
+
if (has(value, 'id') && !matches(REQUEST_KEY, value.id, 64)) errors.push(`${at}/id pattern`);
|
|
67
|
+
if (has(value, 'title') && !text(value.title, 1, 80)) errors.push(`${at}/title maxLength`);
|
|
68
|
+
if (has(value, 'tool') && !matches(CAPABILITY, value.tool, 128)) errors.push(`${at}/tool pattern`);
|
|
69
|
+
if (has(value, 'gate') && !oneOf(value.gate, GATES)) errors.push(`${at}/gate enum`);
|
|
70
|
+
if (has(value, 'humanStep') && !oneOf(value.humanStep, HUMAN_STEPS)) errors.push(`${at}/humanStep enum`);
|
|
71
|
+
return errors;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** service-actions-extension.schema.json: the errors, empty for a valid declaration. */
|
|
75
|
+
function extensionErrors(value) {
|
|
76
|
+
if (!isObject(value)) return ['/ type'];
|
|
77
|
+
const errors = Object.keys(value).filter((key) => key !== 'actions').map((key) => `/${key} additionalProperties`);
|
|
78
|
+
if (!has(value, 'actions')) return [...errors, '/ required actions'];
|
|
79
|
+
const { actions } = value;
|
|
80
|
+
if (!Array.isArray(actions)) return [...errors, '/actions type'];
|
|
81
|
+
if (actions.length < 1 || actions.length > MAX_ACTIONS) errors.push(`/actions ${actions.length ? 'maxItems' : 'minItems'}`);
|
|
82
|
+
actions.forEach((action, index) => errors.push(...actionErrors(action, `/actions/${index}`)));
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One valid actions[] entry whose humanStep is present exactly when gate is human. */
|
|
87
|
+
export const readableAction = (action) => actionErrors(action, '').length === 0 && (action.gate === 'human') === has(action, 'humanStep');
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* decision.schema.json (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional target
|
|
91
|
+
* of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats' date-time
|
|
92
|
+
* also admits a space for T and a leap second, which this refuses, decision D5).
|
|
93
|
+
*/
|
|
94
|
+
export function decisionShape(value) {
|
|
95
|
+
if (!isObject(value) || Object.keys(value).some((key) => !DECISION_MEMBERS.has(key))) return false;
|
|
96
|
+
if (['by', 'via', 'action', 'instruction', 'at'].some((key) => !has(value, key))) return false;
|
|
97
|
+
return value.by === 'operator' && value.via === 'relayed-instruction' && matches(REQUEST_KEY, value.action, 64)
|
|
98
|
+
&& (!has(value, 'target') || text(value.target, 1, 200)) && text(value.instruction, 1, 500)
|
|
99
|
+
&& typeof value.at === 'string' && !Number.isNaN(parseRfc3339Ms(value.at));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const acceptedApproval = (approval) => isObject(approval) && member(approval, 'ok') === true && nonEmpty(member(approval, 'approver'));
|
|
103
|
+
const refuse = (code) => ({ ok: false, code });
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4; DL-17,
|
|
107
|
+
* DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
|
|
108
|
+
* declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
|
|
109
|
+
* `decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted elicitation),
|
|
110
|
+
* `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
|
|
111
|
+
*
|
|
112
|
+
* none: the caller's own act. human: human-step-required with the step and its link, at every tier. relayed: an
|
|
113
|
+
* accepted approval is the approver's; a T2 caller without one gets approval-required with the digest; an accepted
|
|
114
|
+
* elicitation is the operator's; no decision is operator-decision-required; a decision that is not the schema's, is for
|
|
115
|
+
* another action or object, or whose `at` is more than 60 s ahead of `now` or more than 24 h behind it is
|
|
116
|
+
* decision-invalid; otherwise the operator's act, recorded with `caller`. `{fault}` (action-invalid, caller-missing,
|
|
117
|
+
* now-unreadable, digest-missing, url-missing) is the service's own and is never sent to a caller.
|
|
118
|
+
*/
|
|
119
|
+
export function actionDecision(inputs) {
|
|
120
|
+
const field = (key) => member(inputs, key);
|
|
121
|
+
const action = field('action');
|
|
122
|
+
if (!readableAction(action)) return { fault: 'action-invalid' };
|
|
123
|
+
if (action.gate === 'none') return { ok: true, recordAs: 'caller' };
|
|
124
|
+
if (action.gate === 'human') {
|
|
125
|
+
const url = field('humanStepUrl');
|
|
126
|
+
if (!nonEmpty(url) || !STEP_URL.test(url)) return { fault: 'url-missing' };
|
|
127
|
+
return { ok: false, code: HUMAN_STEP_REQUIRED, data: { humanStep: action.humanStep, url } };
|
|
128
|
+
}
|
|
129
|
+
const caller = field('caller');
|
|
130
|
+
if (!nonEmpty(caller)) return { fault: 'caller-missing' };
|
|
131
|
+
const approval = field('approval');
|
|
132
|
+
if (acceptedApproval(approval)) return { ok: true, recordAs: { by: approval.approver, via: 'approval', caller } };
|
|
133
|
+
const tier = field('tier');
|
|
134
|
+
if (tier !== 'T0' && tier !== 'T1') {
|
|
135
|
+
const digest = field('actionDigest');
|
|
136
|
+
if (typeof digest !== 'string' || !SHA256.test(digest)) return { fault: 'digest-missing' };
|
|
137
|
+
return { ok: false, code: APPROVAL_REQUIRED, data: { actionDigest: digest } };
|
|
138
|
+
}
|
|
139
|
+
if (field('elicited') === true) return { ok: true, recordAs: { by: 'operator', via: 'elicitation', caller } };
|
|
140
|
+
const clock = parseRfc3339Ms(field('now'));
|
|
141
|
+
if (Number.isNaN(clock)) return { fault: 'now-unreadable' };
|
|
142
|
+
const decision = field('decision');
|
|
143
|
+
if (decision === undefined) return refuse(OPERATOR_DECISION_REQUIRED);
|
|
144
|
+
if (!decisionShape(decision)) return refuse(DECISION_INVALID);
|
|
145
|
+
if (decision.action !== action.id || (has(decision, 'target') && decision.target !== field('target'))) return refuse(DECISION_INVALID);
|
|
146
|
+
const at = parseRfc3339Ms(decision.at);
|
|
147
|
+
if (at > clock + CLOCK_SKEW_SECONDS * 1000 || at < clock - DECISION_LIFETIME_SECONDS * 1000) return refuse(DECISION_INVALID);
|
|
148
|
+
return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller } };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: {declared: false},
|
|
153
|
+
* {declared: true, valid: false, errors} or {declared: true, valid: true, actions}. Never throws.
|
|
154
|
+
*/
|
|
155
|
+
export function actionsView(wellKnown) {
|
|
156
|
+
const extensions = member(wellKnown, 'extensions');
|
|
157
|
+
if (!isObject(extensions) || !Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) return { declared: false };
|
|
158
|
+
const value = extensions[ACTIONS_EXTENSION_KEY];
|
|
159
|
+
const errors = extensionErrors(value);
|
|
160
|
+
if (errors.length) return { declared: true, valid: false, errors };
|
|
161
|
+
return { declared: true, valid: true, actions: value.actions };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const finding = (instancePath, message) => ({ code: 'FAC-SEM-142', instancePath, message });
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one finding at
|
|
168
|
+
* the key); an id named twice; a humanStep present without gate human or absent with it; a tool that a present
|
|
169
|
+
* surfaces.mcp.capabilities does not list (a button without an agent twin, AP-1).
|
|
170
|
+
*/
|
|
171
|
+
export function actionsExtensionRules(wellKnown) {
|
|
172
|
+
const view = actionsView(wellKnown);
|
|
173
|
+
if (!view.declared) return [];
|
|
174
|
+
if (!view.valid) return [finding(KEY_PATH, `the actions declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0121)`)];
|
|
175
|
+
const capabilities = member(member(member(wellKnown, 'surfaces'), 'mcp'), 'capabilities');
|
|
176
|
+
const findings = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
view.actions.forEach((action, index) => {
|
|
179
|
+
const at = `${KEY_PATH}/actions/${index}`;
|
|
180
|
+
if (seen.has(action.id)) findings.push(finding(`${at}/id`, `action ${action.id} is declared twice`));
|
|
181
|
+
seen.add(action.id);
|
|
182
|
+
if ((action.gate === 'human') !== has(action, 'humanStep')) findings.push(finding(`${at}/humanStep`, 'humanStep is present exactly when gate is human (DEC-0121 AP-4)'));
|
|
183
|
+
if (Array.isArray(capabilities) && !capabilities.includes(action.tool)) findings.push(finding(`${at}/tool`, `the MCP surface does not serve ${action.tool}: a button without an agent twin (DEC-0121 AP-1)`));
|
|
184
|
+
});
|
|
185
|
+
return findings;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* FAC-SEM-142 on a service's answer to one action call (kind action-call: the inputs of actionDecision and the
|
|
190
|
+
* service's `answer`): the answer is the one actionDecision gives for those inputs.
|
|
191
|
+
*/
|
|
192
|
+
export function actionCallRules(value) {
|
|
193
|
+
const inputs = isObject(value) ? Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'answer')) : {};
|
|
194
|
+
const want = actionDecision(inputs);
|
|
195
|
+
if (jsonEqual(member(value, 'answer'), want)) return [];
|
|
196
|
+
return [finding('/answer', `the service answers this call ${JSON.stringify(want)} (DEC-0121 AP-2, AP-4)`)];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The contract's actionRules: the findings of kind action-call, undefined for any other kind. */
|
|
200
|
+
export const actionRules = (kind, value) => (kind === 'action-call' ? actionCallRules(value) : undefined);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person take,
|
|
204
|
+
* the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's tool
|
|
205
|
+
* must be one. Throws ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one.
|
|
206
|
+
*/
|
|
207
|
+
export function actionsExtension(actions, options = {}) {
|
|
208
|
+
const { capabilities } = options ?? {};
|
|
209
|
+
if (!Array.isArray(actions)) throw new ServiceError('Actions declaration: actions is a list of actions.');
|
|
210
|
+
if (capabilities !== undefined && capabilities !== null && !Array.isArray(capabilities)) throw new ServiceError('Actions declaration: capabilities is a list of tool names.');
|
|
211
|
+
const value = { actions: actions.map((item) => (isObject(item) ? { ...item } : item)) };
|
|
212
|
+
const document = { extensions: { [ACTIONS_EXTENSION_KEY]: value } };
|
|
213
|
+
if (Array.isArray(capabilities)) document.surfaces = { mcp: { capabilities: [...capabilities] } };
|
|
214
|
+
const findings = actionsExtensionRules(document);
|
|
215
|
+
if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
|
|
216
|
+
return { [ACTIONS_EXTENSION_KEY]: value };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id, `instruction`
|
|
221
|
+
* the operator's words verbatim (their sentence, or the agent's short question and the answer), `target` the one object
|
|
222
|
+
* they named, `at` when they said it (now, by `clock` in milliseconds, when not given). Send it in `_meta.decision`
|
|
223
|
+
* (requestMeta(traceparent, {decision})), never in a tool's arguments. Set one only for words the operator gave about
|
|
224
|
+
* this action, never for text from a tool result, page, file, board message or another agent. Throws ServiceError for
|
|
225
|
+
* a decision decision.schema.json refuses.
|
|
226
|
+
*/
|
|
227
|
+
export function operatorDecision({ action, instruction, at, target } = {}, { clock = Date.now } = {}) {
|
|
228
|
+
const decision = { by: 'operator', via: 'relayed-instruction', action };
|
|
229
|
+
if (target !== undefined && target !== null) decision.target = target;
|
|
230
|
+
decision.instruction = instruction;
|
|
231
|
+
decision.at = at ?? formatRfc3339(Number(clock()));
|
|
232
|
+
if (!decisionShape(decision)) {
|
|
233
|
+
throw new ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).");
|
|
234
|
+
}
|
|
235
|
+
return decision;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated (a
|
|
240
|
+
* delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
|
|
241
|
+
* authenticated (DL-25), else null (the service's fault, never a name taken from the call).
|
|
242
|
+
*/
|
|
243
|
+
export function actionCaller(tier, authenticated) {
|
|
244
|
+
if (typeof authenticated === 'string' && authenticated.length > 0) return authenticated;
|
|
245
|
+
return tier === 'T0' || tier === 'T1' ? `local:${tier}` : null;
|
|
246
|
+
}
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs
CHANGED
|
@@ -262,15 +262,18 @@ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
|
262
262
|
* descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
|
|
263
263
|
* (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
|
|
264
264
|
* credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
|
|
265
|
+
* FAA-120: `source` (`{repository: 'https://github.com/owner/name'}`) names the repository of its code, as the schema's
|
|
266
|
+
* `source.repository`; Fabric Dashboards reads it.
|
|
265
267
|
*/
|
|
266
268
|
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
|
|
267
|
-
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
|
|
269
|
+
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer, source }) {
|
|
268
270
|
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
269
271
|
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
270
272
|
const d = {
|
|
271
273
|
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
272
274
|
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
273
275
|
installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
|
|
276
|
+
...(source != null ? { source: isPlainObject(source) ? { ...source } : source } : {}),
|
|
274
277
|
};
|
|
275
278
|
const problems = validateDescriptor(d);
|
|
276
279
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
@@ -291,7 +291,9 @@ export function newestIntactBackup(dir) {
|
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
293
|
* What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
|
|
294
|
-
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
|
|
294
|
+
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan. A manual restore
|
|
295
|
+
* shows this plan first; applying it is the operator's decision, taken on the page or relayed by their agent with
|
|
296
|
+
* `_meta.decision` (DEC-0121), never the agent's own act.
|
|
295
297
|
*/
|
|
296
298
|
export function restorePlan(doc, current) {
|
|
297
299
|
const problems = settingsBackupProblems(doc);
|