@passioncode-ai/fabric-agent-adapter 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -0
- package/README.md +5 -4
- package/SECURITY.md +6 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +18 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +30 -6
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +19 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +19 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +226 -26
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +11 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +18 -18
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
"""The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
|
|
3
3
|
that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
|
|
4
4
|
(LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
|
|
5
|
-
only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4).
|
|
5
|
+
only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4).
|
|
6
|
+
|
|
7
|
+
It reads as the contract's ECMAScript does (FAA-136): ECMAScript's whitespace (Python's `\\s` also takes U+0085 and
|
|
8
|
+
U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII case folding, an ASCII word boundary."""
|
|
6
9
|
|
|
7
10
|
from __future__ import annotations
|
|
8
11
|
|
|
9
12
|
import re
|
|
10
|
-
from typing import Dict, List
|
|
13
|
+
from typing import Dict, List, Tuple
|
|
11
14
|
import urllib.parse
|
|
12
15
|
|
|
13
16
|
CREDENTIAL_WORDS = (
|
|
@@ -22,20 +25,28 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
|
22
25
|
"session": ("token", "id"),
|
|
23
26
|
"code": ("login", "auth", "access", "session"),
|
|
24
27
|
}
|
|
28
|
+
#: ECMAScript's \s, as a character class body.
|
|
29
|
+
JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
30
|
+
_NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
|
|
25
31
|
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
26
|
-
SECRET_VALUE = re.compile(r"(^bearer
|
|
27
|
-
re.IGNORECASE | re.
|
|
32
|
+
SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
|
|
33
|
+
r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
|
|
28
34
|
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
29
|
-
_BEARER_INSIDE = re.compile(r"
|
|
30
|
-
|
|
35
|
+
_BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
|
|
36
|
+
re.IGNORECASE | re.ASCII)
|
|
37
|
+
_PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
|
|
38
|
+
_WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
|
|
39
|
+
_ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
|
|
31
40
|
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
41
|
+
#: WHATWG's special schemes that carry an authority with user information (file has none).
|
|
42
|
+
_SPECIAL = ("ftp", "http", "https", "ws", "wss")
|
|
32
43
|
|
|
33
44
|
|
|
34
45
|
def name_words(name: str) -> List[str]:
|
|
35
46
|
"""A name's words: split at `.`, `_`, `-` and camelCase humps, lowercased (`APIKey` is `api key`)."""
|
|
36
47
|
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
37
48
|
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
|
|
38
|
-
return [w.lower() for w in
|
|
49
|
+
return [w.lower() for w in _WORD_SPLIT.split(spaced) if w]
|
|
39
50
|
|
|
40
51
|
|
|
41
52
|
def _with_singulars(words: List[str]) -> List[str]:
|
|
@@ -73,7 +84,7 @@ def secret_argument(value: str) -> bool:
|
|
|
73
84
|
so a folder path holding a session id is not taken for a key."""
|
|
74
85
|
if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
|
|
75
86
|
return True
|
|
76
|
-
for piece in
|
|
87
|
+
for piece in _ARGUMENT_SPLIT.split(value):
|
|
77
88
|
if _path_like(piece):
|
|
78
89
|
if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
|
|
79
90
|
return True
|
|
@@ -93,7 +104,8 @@ def _decode_keeping_plus(raw: str) -> str:
|
|
|
93
104
|
|
|
94
105
|
|
|
95
106
|
def _form_name(text: str) -> str:
|
|
96
|
-
|
|
107
|
+
# URLSearchParams drops one leading `?` from the text it is given (0.56.0 unit r7).
|
|
108
|
+
pairs = urllib.parse.parse_qsl(text[1:] if text.startswith("?") else text, keep_blank_values=True)
|
|
97
109
|
return pairs[0][0] if pairs else ""
|
|
98
110
|
|
|
99
111
|
|
|
@@ -109,23 +121,66 @@ def raw_values(query: str) -> Dict[str, List[str]]:
|
|
|
109
121
|
return values
|
|
110
122
|
|
|
111
123
|
|
|
124
|
+
#: The C0 controls and space, U+0000 to U+0020: what WHATWG URL parsing trims from a URL's ends.
|
|
125
|
+
_C0_OR_SPACE = "".join(map(chr, range(0x21)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _percent_encode(text: str, also: str) -> str:
|
|
132
|
+
"""WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
|
|
133
|
+
above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
|
|
134
|
+
`new URL` hands the contract's secretUrl the text in this form, so a malformed escape left in it is read the same
|
|
135
|
+
way by both ports (0.56.0 unit r8)."""
|
|
136
|
+
out = []
|
|
137
|
+
for char in _LONE_SURROGATE.sub("\ufffd", text):
|
|
138
|
+
if ord(char) <= 0x20 or ord(char) > 0x7E or char in '"<>' or char in also:
|
|
139
|
+
out.append("".join("%%%02X" % byte for byte in char.encode("utf-8")))
|
|
140
|
+
else:
|
|
141
|
+
out.append(char)
|
|
142
|
+
return "".join(out)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _url_parts(value: str) -> Tuple[str, str, str]:
|
|
146
|
+
"""The password, query and fragment of an absolute URL as WHATWG URL parsing splits them, read by hand: urlsplit
|
|
147
|
+
refuses a `[` or `]` outside an IPv6 host (`https://u:p[w@h/`), where `new URL` reads the password (0.56.0 unit
|
|
148
|
+
r7). The fragment starts at the first `#`, the query at the first `?` before it. A special scheme's authority starts
|
|
149
|
+
after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
|
|
150
|
+
next slash, backslash, `?` or `#`, so `https:u:p@h` and `https:\\u:p@h` carry the password p; any other scheme
|
|
151
|
+
has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
|
|
152
|
+
last `@`, and the password follows its first `:`. A host `new URL` would refuse is not checked: such a URL is read
|
|
153
|
+
for its password and parameters all the same, the safe direction for a detector."""
|
|
154
|
+
scheme, _, rest = value.partition(":")
|
|
155
|
+
rest, _, fragment = rest.partition("#")
|
|
156
|
+
rest, _, query = rest.partition("?")
|
|
157
|
+
lowered = scheme.lower()
|
|
158
|
+
if lowered in _SPECIAL:
|
|
159
|
+
authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
|
|
160
|
+
elif lowered != "file" and rest.startswith("//"):
|
|
161
|
+
authority = rest[2:].partition("/")[0]
|
|
162
|
+
else:
|
|
163
|
+
authority = ""
|
|
164
|
+
userinfo, at, _ = authority.rpartition("@")
|
|
165
|
+
return ((userinfo.partition(":")[2] if at else ""), _percent_encode(query, "'" if lowered in _SPECIAL or lowered == "file" else ""), # file is special too
|
|
166
|
+
_percent_encode(fragment, "`"))
|
|
167
|
+
|
|
168
|
+
|
|
112
169
|
def secret_url(value: str) -> bool:
|
|
113
170
|
"""Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
|
|
114
171
|
password in its user information, or a query or fragment parameter whose name can only carry a credential and has
|
|
115
172
|
a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
|
|
116
173
|
if secret_argument(value):
|
|
117
174
|
return True
|
|
118
|
-
|
|
175
|
+
# What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
|
|
176
|
+
# refuses), then remove every tab, CR and LF.
|
|
177
|
+
value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
|
|
178
|
+
if not _SCHEME.match(value):
|
|
119
179
|
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
except ValueError:
|
|
123
|
-
return False
|
|
124
|
-
if not parts.scheme:
|
|
125
|
-
return False
|
|
126
|
-
if parts.password:
|
|
180
|
+
password, query, fragment = _url_parts(value)
|
|
181
|
+
if password:
|
|
127
182
|
return True
|
|
128
|
-
for part in (
|
|
183
|
+
for part in (query, fragment):
|
|
129
184
|
for name, raws in raw_values(part).items():
|
|
130
185
|
if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
|
|
131
186
|
return True
|
|
@@ -14,10 +14,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
14
14
|
compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
15
15
|
metadata:
|
|
16
16
|
author: PassionCode.ai
|
|
17
|
-
version: "0.
|
|
17
|
+
version: "0.57.0"
|
|
18
18
|
contract-version: "0.1.0"
|
|
19
19
|
extension: "fabric-service/0.1"
|
|
20
|
-
extension-commit: "
|
|
20
|
+
extension-commit: "3830a3353923e20f826d466610e1c479f120bec0"
|
|
21
21
|
---
|
|
22
22
|
|
|
23
23
|
# Building Fabric services
|
|
@@ -104,8 +104,8 @@ responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) f
|
|
|
104
104
|
every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
|
|
105
105
|
The request body is data behind the service's own input gate. From 0.53.0 a slot the operator moved to another
|
|
106
106
|
session stays moved until `take_back` (`takeBack`): give it an operator action with its tool and gate `relayed`, or it
|
|
107
|
-
stays moved for good (
|
|
108
|
-
or its elicitation). Read [the board reference](references/board.md).
|
|
107
|
+
stays moved for good (the operator reaches it through an agent that relays their decision in `_meta`, the service's
|
|
108
|
+
page or its elicitation; Claude Code and Codex wait for CT-125's forwarding). Read [the board reference](references/board.md).
|
|
109
109
|
|
|
110
110
|
## Step 2: the non-negotiables
|
|
111
111
|
|
|
@@ -260,8 +260,9 @@ or `human` with its `humanStep`) and declared with `build_well_known(actions=ser
|
|
|
260
260
|
`extensions/actions/0.1`. A `relayed` action called by the operator's own agent with their decision in
|
|
261
261
|
`_meta.decision` is the operator's act: never answer it "operator session required" or send them to the page.
|
|
262
262
|
A service adopts the relayed gate once Fabric's hub forwards decisions (CT-125), as the contract says; until then its tools
|
|
263
|
-
answer as today, except the board responder's take-back action, declared `relayed` now
|
|
264
|
-
|
|
263
|
+
answer as today, except the board responder's take-back action, declared `relayed` now: an agent whose client sets
|
|
264
|
+
`_meta` calls it with the operator's decision; Claude Code and Codex reach it through the page until CT-125
|
|
265
|
+
([board.md](references/board.md); the reference service's `board.take-back`). Read the interop reference's *Actions* section for when to adopt, the gate, its hooks and its
|
|
265
266
|
refusals.
|
|
266
267
|
|
|
267
268
|
**Say what the agent uses** (`fabric-connections/0.1`, DEC-0096) so Fabric Dashboards draws its chain: the other
|
|
@@ -42,7 +42,9 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
|
|
|
42
42
|
|
|
43
43
|
The responder runs inside the service process, started after the instance lock like every other piece of state
|
|
44
44
|
(SKILL.md, Step 3). [`sample_service.py`](../scripts/sample_service.py) does it behind `serve --board-project <uuid>`,
|
|
45
|
-
off by default
|
|
45
|
+
off by default (register it with the same `--board-project`, so its manifest lists the take-back tool below; a serve
|
|
46
|
+
whose board mode differs from the registered manifest at its start logs `service.manifest.mismatch` and leaves the
|
|
47
|
+
manifest as it is):
|
|
46
48
|
|
|
47
49
|
```python
|
|
48
50
|
import fabric_board as fbd, fabric_hub as fhb
|
|
@@ -53,12 +55,13 @@ responder = fbd.Responder(
|
|
|
53
55
|
handler=render, admit=gate, observe=look_on_disk, emit=log.append, service="example-agent")
|
|
54
56
|
board = threading.Thread(target=responder.run, args=(stop_event, 5.0), daemon=True) # SIGTERM: set stop_event
|
|
55
57
|
board.start()
|
|
56
|
-
# ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets
|
|
57
|
-
# records, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
|
|
58
|
+
# ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets the slot
|
|
59
|
+
# records it confirmed, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
|
|
58
60
|
board.join(timeout)
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
Node: `new Responder({...same names in camelCase})`, then `responder.run({ signal, drain })
|
|
63
|
+
Node: `new Responder({...same names in camelCase})`, then `const running = responder.run({ signal, drain })`; at stop,
|
|
64
|
+
abort the signal and `await running`, bounded inside the hard exit (the run leaves its board session before it settles). A service that lists
|
|
62
65
|
`fabric-project-comms/0.1` in its well-known `protocols` (DEC-0067: only once its hosts are pinned at or after it)
|
|
63
66
|
declares that it answers board requests; the probe checks that declaration.
|
|
64
67
|
|
|
@@ -144,6 +147,28 @@ answers `not_available` or `capacity_exceeded`, which do not say whether the rep
|
|
|
144
147
|
in line and the next tick asks again, and a restart asks with the same old fence. A request leaves the resume queue only
|
|
145
148
|
once it has resumed: when the `com.complete` that sends a known outcome loses its answer, the next tick sends the same
|
|
146
149
|
key again in the same session (FAA-86). `shutdown()` drains, then leaves the session (its DELETE releases the slot).
|
|
150
|
+
An idle responder waits 3 s at most for that DELETE (`IDLE_LEAVE_SECONDS`), so an idle stop ends within the org's 5 s
|
|
151
|
+
(LC-01, FAA-173); one with work waits up to the session's 15 s client timeout. A board call already in flight when the
|
|
152
|
+
stop comes (a poll against a slow hub) runs first; when the service's bounded join ends before the DELETE is sent, the
|
|
153
|
+
slot lapses on the board's clock as before. The slot records are forgotten only after
|
|
154
|
+
a DELETE Fabric answered with a 2xx (a 404, the session already gone, keeps them: a move made before it ended is read at the next `com.status`), also when a session is dropped on a live board (FAA-163): an unanswered one keeps
|
|
155
|
+
them, and the slot lapses in 60 s.
|
|
156
|
+
|
|
157
|
+
**One capability at a time (FAA-163).** At connect (and every reconnect) the responder starts with no slot. Each
|
|
158
|
+
capability's enroll or replace is settled on its own: a refusal of that capability (`capacity_exceeded`,
|
|
159
|
+
`invalid_arguments`, any code that is not about the board session) leaves it `retry`, asked again alone after 15 s,
|
|
160
|
+
30 s, doubling to 15 minutes, with one `board.slot.refused` event (warning; said again if it is refused again after
|
|
161
|
+
the slot was held); its accepted work waits `lapsed` and goes back
|
|
162
|
+
by a transfer once the slot is held. The capabilities after it are recovered as before. A refusal of the board session
|
|
163
|
+
(`unreachable`, `session-ended`, `not_available`, `not_authorized` and the other codes in `SESSION_CODES`) leaves the
|
|
164
|
+
rest `retry` too, says no `board.slot.refused`, and goes to the responder's fault, which says it by code:
|
|
165
|
+
`board.access.needed` for `not_authorized`, `authority_changed` and `credential` (the session is left),
|
|
166
|
+
`board.blocked` for a defect (`no-epoch`, `schema-unsupported`, `checkpoint`, `receipt-shape`,
|
|
167
|
+
`idempotency_conflict`), and `board.down` for the rest: `unreachable` keeps the session while a held slot's lease runs
|
|
168
|
+
(so a lost enroll of one capability is asked again at its next retry), `not_available` keeps it except at connect or recovery with no slot held,
|
|
169
|
+
and `session-ended`, `not-running` and the other hub codes drop it (as a fault during connect or recovery does) and
|
|
170
|
+
reconnect. A slot whose lease lapsed (`lease_expired`) holds
|
|
171
|
+
no lease for that decision.
|
|
147
172
|
|
|
148
173
|
## When the operator holds work (DEC-0079)
|
|
149
174
|
|
|
@@ -178,9 +203,16 @@ held and not transferred). The responder keeps that move instead of undoing it:
|
|
|
178
203
|
{project, generation, moved?, asking?}}`). The record is written when a receipt's generation changes and before an
|
|
179
204
|
enroll or a replace that can change it; a live holder's renew (about every 20 s) cannot, and writes nothing. At a
|
|
180
205
|
clean leave (the service stopped; the session's DELETE released its slots) the record of a slot with no request left
|
|
181
|
-
in the checkpoint and not moved is forgotten
|
|
182
|
-
|
|
183
|
-
|
|
206
|
+
in the checkpoint and not moved is forgotten when this session confirmed its generation (held it, paused under the
|
|
207
|
+
operator's hold included, or read it unmoved in `com.status`) and no fenced call has put in doubt since: the slot is
|
|
208
|
+
free, and whoever takes it next is no move. An unconfirmed record stays, since the slot may have moved while the
|
|
209
|
+
service was down, and a move this session saw keeps its record, so a clean stop never undoes it. A paused or
|
|
210
|
+
retrying slot reads `com.status` before each new ask. A move made after the slot's last renew or check and before
|
|
211
|
+
the stop, with no call since, is not seen at the leave: its record is forgotten, and the next start reads the slot
|
|
212
|
+
as free and may enroll it once the other holder lets it go (FAA-187). A file without the record
|
|
213
|
+
reads as before; a kit before 0.53.0 reads the file, ignores the record and drops it at its first checkpoint write. So a service stopped cleanly with no request left in its checkpoint, its DELETE answered 2xx and every slot confirmed
|
|
214
|
+
in that session, rolled back to 0.52 and up again starts as on a first start. Otherwise (a 404, the idle wait running
|
|
215
|
+
out, a slot this session never confirmed, one left retrying before `com.status` answered for it included) its records stay, and the case is the crash case below. After a crash the record stays: if 0.52 then ran, enrolled the slot and
|
|
184
216
|
wrote nothing to the checkpoint, the next 0.53 start reads that enroll as a move; bring it back with the service's take-back action (below).
|
|
185
217
|
- An enroll or a replace is written into that record with its key before it is sent (`asking`). When its answer is
|
|
186
218
|
lost, the next connect (or the next enroll) sends the same call again under the same key; the board answers the
|
|
@@ -205,8 +237,8 @@ held and not transferred). The responder keeps that move instead of undoing it:
|
|
|
205
237
|
holder, or the estate owner, reconciles it now). No `com.claim`, `com.renew`, `com.complete`, `com.enroll` or
|
|
206
238
|
`com.responder_replace` is sent for that capability again: not at recovery after a restart, and not when the new
|
|
207
239
|
holder's lease lapses, nor when com.status later names no generation for the slot (a recorded move stays until
|
|
208
|
-
take-back). Accepted requests stay
|
|
209
|
-
will hand them to the new holder). One `board.slot.moved` event (
|
|
240
|
+
take-back). Accepted requests stay `accepted` on the board, unanswered, until the slot is taken back (CT-175
|
|
241
|
+
will hand them to the new holder). One `board.slot.moved` event (warning, notify) says so, once, also across restarts
|
|
210
242
|
(the record keeps `moved`).
|
|
211
243
|
- A responder that never held the slot (a first start: no record, or a record of another Project), or whose record
|
|
212
244
|
equals the current generation, behaves as before: it enrolls, recovers and replaces its own lapsed work. A recorded or
|
|
@@ -216,18 +248,28 @@ held and not transferred). The responder keeps that move instead of undoing it:
|
|
|
216
248
|
records its own generation. Only a replay the board refuses, or one that ran for the first time (`repeated: false`)
|
|
217
249
|
after the slot had moved past the record, reads as a move: the safe side; a slot such a replay took is left to lapse
|
|
218
250
|
unused. A generation written `2.0` is `2`.
|
|
219
|
-
- Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record
|
|
251
|
+
- Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
|
|
252
|
+
being read at the same time: the move is written under the responder's lock (Node: on the fence queue, after any fenced
|
|
253
|
+
call in flight) and only while the record is unchanged (FAA-158). Never edit the
|
|
220
254
|
checkpoint's `slots` record by hand to undo a move. In the same board
|
|
221
255
|
session the next tick enrolls once the other session lets the slot go (a live holder is waited for) and takes the work
|
|
222
|
-
left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work
|
|
256
|
+
left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work waiting for the slot, takes it
|
|
223
257
|
back at once by `com.responder_replace`, as a restart did before 0.53.0: the operator asked for the slot back, so the
|
|
224
258
|
other session is replaced. In recovery, a refusal that leaves no slot held reconnects; one that leaves some held keeps
|
|
225
|
-
the session and serves them. The kit gives it no route of its own
|
|
226
|
-
FAA-157): a
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
`
|
|
230
|
-
|
|
259
|
+
the session and serves them. The kit gives it no route of its own. The reference service (`sample_service.py`) offers
|
|
260
|
+
it on its dashboard (FAA-157): a moved slot shows a Take back button that posts `POST /api/board/take-back` from the
|
|
261
|
+
operator's signed-in session with the `X-Fabric-Request` header; the service token alone is refused. Since 0.56.0 the
|
|
262
|
+
button has its tool, `board.take-back` with `{capability}`, declared under `extensions/actions/0.1` with gate
|
|
263
|
+
`relayed` and target `capability`, and a capability of the manifest like every tool the well-known lists
|
|
264
|
+
(`interop.well-known-capabilities`): an agent whose MCP client sets `_meta` calls it over `/mcp` with the operator's
|
|
265
|
+
decision in `_meta.decision` (a call without one is `operator-decision-required`); Claude Code and Codex send
|
|
266
|
+
arguments only, so until CT-125 they hand the operator the page as Fabric Dashboards' `open_link`. Either door records
|
|
267
|
+
the take-back as a `board.slot.returned` event (info, the capability as its subject) whose sentence says the route in
|
|
268
|
+
words and whose `data` holds `by`, `via` and the `caller` (none when the feed refuses the caller), never the decision's words; a repeat after a lost answer is answered `{capability, moved: false}`. A service built on the kit declares it as an operator action, a button with its tool ([Every button has a
|
|
269
|
+
tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so a call
|
|
270
|
+
without the operator's relayed decision is refused; on a server that reads mandates (`agent_id`) it also names its effect class, for example
|
|
271
|
+
`change-policy` (DEC-0125); the operator reaches it through an agent that relays their decision in `_meta`, the
|
|
272
|
+
service's page or its elicitation (Claude Code and Codex, which cannot set `_meta`, wait for CT-125's forwarding), and the service names it in its docs; otherwise a moved slot stays moved. A move back to this service's own session is not
|
|
231
273
|
detected yet: the receiving side and the hand-over of accepted requests (contract CT-175) wait for Fabric's session
|
|
232
274
|
surface.
|
|
233
275
|
|
|
@@ -251,11 +293,19 @@ raises `directory-invalid` rather than answering; an unlisted Project may still
|
|
|
251
293
|
|
|
252
294
|
## Events, the probe and the live smoke
|
|
253
295
|
|
|
254
|
-
Events go to `emit(kind, level, text, notify=…, data=…)` (`JsonlEventLog.append` fits
|
|
296
|
+
Events go to `emit(kind, level, text, notify=…, subject=…, data=…)` (`JsonlEventLog.append` fits: every level is one
|
|
297
|
+
of its `info`, `notice`, `warning`, `error`; `subject` is `{type, id}`, the notice's request, else its capability, else
|
|
298
|
+
its episode (such as a block's cause), else the Project; in Python it goes only to an `emit` whose signature names a `subject`
|
|
299
|
+
parameter with no value bound, as `JsonlEventLog.append` does, and any other `emit`, one with only `**kwargs`, one with
|
|
300
|
+
a subject bound by `functools.partial` (which then stands) or one whose signature cannot be read, is called once, without
|
|
301
|
+
one, as before 0.56.0; the Node responder always passes it in its options, which an `emit` that does not read it ignores) with
|
|
302
|
+
`data {schema, value}`
|
|
255
303
|
checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
|
|
256
304
|
`board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
|
|
257
|
-
`board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.access.needed`, `board.blocked`,
|
|
258
|
-
`board.down`, each notifying episode said once.
|
|
305
|
+
`board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.slot.refused`, `board.access.needed`, `board.blocked`,
|
|
306
|
+
`board.down`, each notifying episode said once, and `board.outcome.invalid` (warning) when a handler's outcome fails
|
|
307
|
+
its checks and is not sent. The operator's hold (below) adds `board.held`, `board.slot.held`,
|
|
308
|
+
`board.slot.released` and `board.request.waiting`. The reference service adds `board.slot.returned` at a take-back.
|
|
259
309
|
|
|
260
310
|
`check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
|
|
261
311
|
`NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md
CHANGED
|
@@ -45,7 +45,12 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
|
|
|
45
45
|
|
|
46
46
|
Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
|
|
47
47
|
`connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
|
|
48
|
-
host, port and path (`redact_url`)
|
|
48
|
+
host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`), and
|
|
49
|
+
one that does not parse as a URL is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
|
|
50
|
+
normalises it, Python as given), so a key never belongs in it. Python's `check_url` refuses a host
|
|
51
|
+
with a backslash, space, control character (C0, DEL or C1) or `%`, a URL with a space or ASCII control character before its
|
|
52
|
+
first `#` (the default transport sends a URL up to there, as Node's does; a transport you pass receives the URL as given), or a character outside ASCII in its path or query (ASCII whitespace at its ends is trimmed, as urllib and `new URL` trim it; a Unicode space ending the part before the first `#` is refused, where Node keeps it percent-encoded), a bracketed host that is not an IPv6 address with no zone, and a port that is not
|
|
53
|
+
ASCII digits, all as kind `refused` before sending; it checks a host's shape, not its name. `redact_url` on Python reads a host holding `%` as `<unparseable url>`; Node writes it decoded. An error never carries a request header or body.
|
|
49
54
|
|
|
50
55
|
## fabric_openrouter
|
|
51
56
|
|
|
@@ -77,6 +82,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
|
|
|
77
82
|
| The fallback | required, of another vendor (the part before `/` differs) |
|
|
78
83
|
| Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
|
|
79
84
|
| What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
|
|
85
|
+
| The base URL | `base_url` (`baseUrl`), by default `https://openrouter.ai/api/v1`: checked by `check_url` when the client is built; ASCII whitespace and trailing `/` at its ends are trimmed (Node also trims every C0 control at the ends and removes a tab or line break anywhere, as `new URL` reads them); a base that is not a string is refused as `check_url` refuses it (`HttpError` kind `refused`, both twins); a query or a fragment is refused (kind `config`, both twins, before `check_url`). Python also refuses a space, a control or a non-ASCII character in the path (`check_url`'s `HttpError` kind `refused`); Node sends it percent-encoded, as `new URL` writes it (FAA-187 (18)) |
|
|
80
86
|
| Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
|
|
81
87
|
| Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
|
|
82
88
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -110,7 +110,9 @@ issuer's fault, `issuer-unavailable` (DEC-0045); a gate without a trusted issuer
|
|
|
110
110
|
DEC-0121 adds three codes to the one set, each 403: `operator-decision-required` (a `relayed` action called without the
|
|
111
111
|
operator's decision), `decision-invalid` (a decision for another action or object, outside its 24 hours, or malformed)
|
|
112
112
|
and `human-step-required`, which carries `data.humanStep` (`sign-in`, `consent`, `payment`, `secret`,
|
|
113
|
-
`spend-above-limit`, `passkey
|
|
113
|
+
`spend-above-limit`, `passkey`, and since DEC-0142 `comparison`: a comparison of codes between two people or the choice
|
|
114
|
+
to share without one; a reader pinned before DEC-0142 refuses a whole declaration that names it, and a caller that validates the refusal
|
|
115
|
+
against a `delegation-refusal.schema.json` pinned before DEC-0142 refuses that refusal) and `data.url` (an https URL or a path on the service's origin) beside `detail`.
|
|
114
116
|
`refusal_from_action` / `refusalFromAction` turn an action decision into that refusal; a service's own fault never
|
|
115
117
|
reaches the caller and reads as `unknown-state`, but for `mandate-capability-missing` (DEC-0141 MJ-1): the caller
|
|
116
118
|
reads `operator-decision-required` with that `data.detail`, since no mandate covers the step and only a decision does. Since 0.51.0 the `operator-decision-required` message (free text in
|
|
@@ -124,6 +126,15 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
|
|
|
124
126
|
`JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
|
|
125
127
|
a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
|
|
126
128
|
`issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
|
|
129
|
+
Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
|
|
130
|
+
reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
|
|
131
|
+
and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
|
|
132
|
+
part before the first `#` is refused, as `check_url` refuses it, because Node keeps it percent-encoded (urllib would
|
|
133
|
+
trim it and read the keys when it ends the URL); a leading space is refused as not a URL), a host holding `%`, a backslash, a Unicode space or a control character (urllib
|
|
134
|
+
would decode or dial it as another host), a bracketed host that is no IPv6 address, a port that is not ASCII digits or is above
|
|
135
|
+
65535, no host, and an empty userinfo. The Node kit accepts several of these as `new URL` reads them, among them a `%`
|
|
136
|
+
host decoded, a backslash as `/`, a tab or line break removed, an empty userinfo dropped and an extra slash before the
|
|
137
|
+
host skipped. The
|
|
127
138
|
Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
|
|
128
139
|
canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
|
|
129
140
|
itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
|
|
@@ -149,7 +149,8 @@ operator's own coding agents: Claude Code and Codex cannot set `_meta`, so until
|
|
|
149
149
|
a `relayed` gate turns "approve it" in chat into `operator-decision-required` with the page as the only route.
|
|
150
150
|
Until then a service keeps its tools as they answer today, or gates a `relayed` tool only where it offers its own
|
|
151
151
|
elicitation (the `elicited` hook). The one exception is the board responder's take-back action: it is declared
|
|
152
|
-
`relayed` now
|
|
152
|
+
`relayed` now (the reference service's `board.take-back`); an agent whose MCP client sets `_meta` calls it with the
|
|
153
|
+
operator's decision, and Claude Code and Codex reach it through the page until CT-125 (`board.md`). This is D8's reason, which keeps `input_actions` off, applied to tools.
|
|
153
154
|
|
|
154
155
|
**Parity (AP-1).** Every action the dashboard, app or page lets a person take (approve, reject, choose, edit, pause,
|
|
155
156
|
resume, cancel, retry, send, publish, restore, set a policy, any other) has a tool on the MCP surface with the same
|
|
@@ -160,7 +161,11 @@ dialog is one of those checks; an instruction that names the action satisfies it
|
|
|
160
161
|
|---|---|---|
|
|
161
162
|
| `none` | an act on the caller's own account: refresh, read, retry or cancel its own job | the caller's own act |
|
|
162
163
|
| `relayed` | a decision on another party's proposal or draft, a send, a publish, a deletion, a spend within a limit, a restore, a policy change | the operator's act when a T0 or T1 caller carries a valid decision; else a refusal below |
|
|
163
|
-
| `human` with `humanStep` | `sign-in`, `consent`, `payment`, `secret`, `spend-above-limit`, `passkey` (AP-4): they stay the person's | `human-step-required` with `data.humanStep` and `data.url`, the direct link to that step |
|
|
164
|
+
| `human` with `humanStep` | `sign-in`, `consent`, `payment`, `secret`, `spend-above-limit`, `passkey`, `comparison` (a comparison of codes between two people or the choice to share without one, DEC-0142) (AP-4): they stay the person's | `human-step-required` with `data.humanStep` and `data.url`, the direct link to that step |
|
|
165
|
+
|
|
166
|
+
Declare `comparison` only to readers at or after DEC-0142: a reader pinned before it (a 0.56.0 or older kit, Fabric
|
|
167
|
+
Dashboards before 0.8.9) refuses the whole declaration, every action in it, and FAC-SEM-142 then reads it as not declared. Fabric's hub reads no declaration and forwards the step unchanged
|
|
168
|
+
(CT-186).
|
|
164
169
|
|
|
165
170
|
```python
|
|
166
171
|
server = fi.McpToolServer("example-agent", "1.0.0", jobs=jobs, authorize=gate,
|
|
@@ -201,8 +206,11 @@ well_known = fs.build_well_known(..., surfaces=surfaces, actions=server.action_t
|
|
|
201
206
|
`caller-missing`: an action-gated server must pass `tier`, `identify` or `authorize`,
|
|
202
207
|
since `handle` defaults to T2 and a T2 call names nobody without them). One fault reaches the caller by name:
|
|
203
208
|
`mandate-capability-missing` (DEC-0141 MJ-1), read as `operator-decision-required` with that `data.detail`.
|
|
204
|
-
`on_fault` hears it
|
|
205
|
-
|
|
209
|
+
`on_fault` hears it under the job's capability (the name the job was created under) both from `ctx.approval_step`
|
|
210
|
+
at a job's start, where the caller sees no refusal: the job waits `input_required`, its message naming the fault,
|
|
211
|
+
and at an answer through `fabric.job.get`, where the call is refused; at the start it hears any fault `job_step`
|
|
212
|
+
answers (also `now-unreadable` from the server's clock). A capability name is what `^[a-z][a-z0-9._-]{1,127}$`
|
|
213
|
+
matches (`reviews.approve`, never `reviewsApprove`); a job created under another name is read under that name.
|
|
206
214
|
- **Arguments with no canonical form** (a lone surrogate, a NaN or an infinity that Python's `json.loads` lets in,
|
|
207
215
|
nesting past the recursion limit) have no digest to bind: a relayed tool answers JSON-RPC `-32602`, never an exception.
|
|
208
216
|
- **The handler** reads `ctx.record` (`"caller"`, or `{by, via, caller}`) and writes it into the service's audit
|
|
@@ -305,7 +313,10 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
|
|
|
305
313
|
step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
|
|
306
314
|
step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
|
|
307
315
|
/ `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
|
|
308
|
-
`requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part.
|
|
316
|
+
`requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Node has no
|
|
317
|
+
`approvalStep`, so a Node service calls `jobStep` itself and passes `{}` when the start call's `_meta` carried
|
|
318
|
+
`mandate: null` (`Object.hasOwn(meta, 'mandate') && meta.mandate === null ? {} : meta.mandate`): `null` is no mandate
|
|
319
|
+
to `jobStep`, while the answer reads it as an unreadable one (FAA-178). Each `request_input`
|
|
309
320
|
replaces the kept steps, and an answer clears them: pass `steps=` again on every question whose answer a mandate may
|
|
310
321
|
cover. The answer's mandate is read under the job's own capability, as at the start (`action_gate(...,
|
|
311
322
|
mandate_capability=)` / `actionGate({mandateCapability})`), so the start and the answer agree whatever the tool is
|
|
@@ -328,7 +339,9 @@ lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent
|
|
|
328
339
|
`hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
|
|
329
340
|
wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
|
|
330
341
|
sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
|
|
331
|
-
`FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call.
|
|
342
|
+
`FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. To cover a
|
|
343
|
+
job's approval steps, name the job's capability in `capabilities` (or leave `capabilities` out) and the steps' keys in
|
|
344
|
+
`actions`; never `fabric.job.get`, which covers no step of another capability (DEC-0141 MJ-1). Never write a
|
|
332
345
|
mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
|
|
333
346
|
|
|
334
347
|
A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -87,6 +87,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
|
|
|
87
87
|
| L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
|
|
88
88
|
| L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
|
|
89
89
|
| L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
|
|
90
|
+
| L4 | `fabric_board_slots` | the responder's slot records: the generation held, a move and its take-back, one capability's refusal retried alone (FAA-163) |
|
|
90
91
|
| L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
|
|
91
92
|
| L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
|
|
92
93
|
| L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -55,7 +55,7 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
|
|
|
55
55
|
|
|
56
56
|
| Id | Rule | Here | Kit (Python / Node) |
|
|
57
57
|
|---|---|---|---|
|
|
58
|
-
| LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (
|
|
58
|
+
| LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (an idle board responder waits 3 s at most for its session's DELETE, FAA-173; one with work can take up to that hard exit). Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
59
59
|
| LC-02 | **Every child has an owner, a deadline and a process group.** No subprocess without a timeout; on timeout or the parent's exit the whole group goes; no orphan survives. Locks and shared resources are released in `finally` | your own code | none |
|
|
60
60
|
| LC-03 | **A job is bounded and exclusive.** One copy holds `service.lock`; a scheduled job has a watchdog shorter than its interval, shares its lock with every manual entry point, bounds every network call and records each run's outcome. Long work is journalled before it starts | [One copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
|
|
61
61
|
| LC-04 | **Credentials are silent after setup.** No background prompt; a credential read that is denied or absent is cached and backs off until a person acts | [Directories](#directories) (secret files 0600) | none |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1: wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `3830a3353923e20f826d466610e1c479f120bec0`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
@@ -23,8 +23,10 @@ DEC-0124; the operator's mandate `mandate/1`, handed on narrowed with a task DEC
|
|
|
23
23
|
operator's agents, its `source` on a mandate and a decision DEC-0126; context hub keys and the organizations an estate
|
|
24
24
|
record pins DEC-0127 and DEC-0128; the family and the agent network named DEC-0130 and DEC-0137; a reply delivered to
|
|
25
25
|
the sender it answers, or to whom it names DEC-0135; a hand-over adds no naming list the scope in force leaves out
|
|
26
|
-
DEC-0136; a job's step under the mandate read under the job's own capability DEC-0141
|
|
27
|
-
|
|
26
|
+
DEC-0136; a job's step under the mandate read under the job's own capability DEC-0141; a person's comparison of codes as a human
|
|
27
|
+
step DEC-0142. The organization's member list, draft exchange, relay and the machines' report, DEC-0129, DEC-0131 to
|
|
28
|
+
DEC-0134 and DEC-0139, an organization's adaptation DEC-0138 and the peer share DEC-0142, reach the kit only as
|
|
29
|
+
protocol register entries). The contract's schemas are normative;
|
|
28
30
|
this page is the working summary. Extension key:
|
|
29
31
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
30
32
|
|
|
@@ -162,7 +164,10 @@ path on the service origin, never an absolute URL. Optional SSE stream at
|
|
|
162
164
|
**Machine-readable `data` (DEC-0070).** An event may carry `data {schema, value}` beside its sentence: `schema` is the
|
|
163
165
|
absolute URI of a payload schema the service publishes; `value` is an object of at most 32 members, each a scalar (a
|
|
164
166
|
string of at most 500 characters, a number, a boolean, null), a list of at most 32 scalars, or an object of at most 32
|
|
165
|
-
scalar members
|
|
167
|
+
scalar members: two levels at most, so `value.cited` may be a list of ids or an object of counts, never a list of
|
|
168
|
+
objects (FAA-122). Flatten a nested record into members of its own, `{"citedIds": ["a1", "b2"], "citedCoverage":
|
|
169
|
+
[0.8, 0.4]}` in place of `{"cited": [{"id": "a1", "coverage": 0.8}, ...]}`; the refusal names the first place that
|
|
170
|
+
nests too deep (`data.value.cited[0] is an object`). `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
|
|
166
171
|
treats `value` as opaque. No string in `value` sits under a credential's name or has a credential's shape
|
|
167
172
|
(`FAC-SEM-101`); personal data is the service's own duty. `make_event` / `makeEvent` (and `JsonlEventLog.append`) take
|
|
168
173
|
`data=` and refuse one that breaks either rule, naming the place, never the value; `check_service.py` checks the feed it
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs
CHANGED
|
@@ -37,7 +37,8 @@ export const DECISION_INVALID = 'decision-invalid';
|
|
|
37
37
|
export const HUMAN_STEP_REQUIRED = 'human-step-required';
|
|
38
38
|
export const APPROVAL_REQUIRED = 'approval-required';
|
|
39
39
|
export const GATES = Object.freeze(['none', 'relayed', 'human']);
|
|
40
|
-
|
|
40
|
+
/** AP-4's steps a person takes; `comparison` (DEC-0142) is a comparison of codes between two people. */
|
|
41
|
+
export const HUMAN_STEPS = Object.freeze(['sign-in', 'consent', 'payment', 'secret', 'spend-above-limit', 'passkey', 'comparison']);
|
|
41
42
|
export const MAX_ACTIONS = 128;
|
|
42
43
|
/** How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS). */
|
|
43
44
|
export const CLOCK_SKEW_SECONDS = 60;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-boar
|
|
|
10
10
|
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
11
11
|
export class BoardSession {
|
|
12
12
|
constructor(grant, { timeout = 15000, post = postMcp } = {}) {
|
|
13
|
-
Object.assign(this, { grant, timeout, post });
|
|
13
|
+
Object.assign(this, { grant, timeout: timeout > 0 ? timeout : 15000, post }); // not above 0 is the default
|
|
14
14
|
this.mcpSession = null;
|
|
15
15
|
this.protocolVersion = MCP_PROTOCOL_VERSION;
|
|
16
16
|
this.rpc = 0;
|
|
@@ -23,9 +23,9 @@ export class BoardSession {
|
|
|
23
23
|
return run;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
async #send(body, method = 'POST') {
|
|
26
|
+
async #send(body, method = 'POST', timeout = null) {
|
|
27
27
|
try {
|
|
28
|
-
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
28
|
+
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
29
29
|
} catch (e) {
|
|
30
30
|
if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
|
|
31
31
|
throw e;
|
|
@@ -66,15 +66,19 @@ export class BoardSession {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/** Sends the DELETE (waiting `timeout` ms at most); true when Fabric answered it with a 2xx, so the slots are free now (FAA-163). A 404 (already gone) is false: a move made before it ended is read at the next com.status, so the slot records stay. */
|
|
70
|
+
close(timeout = null) {
|
|
70
71
|
return this.#serial(async () => {
|
|
71
|
-
if (!this.mcpSession) return;
|
|
72
|
+
if (!this.mcpSession) return false;
|
|
73
|
+
let status = 0;
|
|
72
74
|
try {
|
|
73
|
-
await this.#send({}, 'DELETE');
|
|
75
|
+
const answer = await this.#send({}, 'DELETE', timeout);
|
|
76
|
+
status = Number.isInteger(answer?.status) ? answer.status : 0; // an answer it cannot read is no release, as in Python
|
|
74
77
|
} catch {
|
|
75
78
|
// best effort: the lease lapses in 60 s anyway
|
|
76
79
|
}
|
|
77
80
|
this.mcpSession = null;
|
|
81
|
+
return status >= 200 && status < 300;
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -135,14 +139,17 @@ export class Checkpoint {
|
|
|
135
139
|
this.slots = slots;
|
|
136
140
|
}
|
|
137
141
|
/**
|
|
138
|
-
* At a clean leave (the session's DELETE released the slots): forget the record of each slot this
|
|
139
|
-
* `project
|
|
142
|
+
* At a clean leave (the session's DELETE released the slots): forget the record of each slot this session confirmed
|
|
143
|
+
* (`held`, required: capability to the generation it held, or com.status showed unmoved) at that generation or below, for `project`, with no request left and not moved away, so a later generation is another session taking a free slot, and
|
|
140
144
|
* a kit rolled back to before 0.53.0 leaves nothing that reads as a move. A record that cannot be written stays.
|
|
141
145
|
*/
|
|
142
|
-
leave(project) {
|
|
146
|
+
leave(project, held) {
|
|
143
147
|
const busy = new Set(Object.values(this.entries).map((e) => e.capability));
|
|
144
148
|
for (const [capability, record] of Object.entries(this.slots)) {
|
|
145
149
|
if (!isObj(record) || record.project !== project || 'moved' in record || busy.has(capability)) continue;
|
|
150
|
+
// Only a slot this session confirmed, at or below that generation: an unconfirmed record may be a move (0.56.0 seam r2, r3).
|
|
151
|
+
const confirmed = Object.hasOwn(held, capability) ? held[capability] : null;
|
|
152
|
+
if (!Number.isInteger(record.generation) || record.generation < 1 || !Number.isInteger(confirmed) || record.generation > confirmed) continue;
|
|
146
153
|
try {
|
|
147
154
|
this.slot(capability, null);
|
|
148
155
|
} catch {
|