@passioncode-ai/passioncode 0.2.39 → 0.2.40
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 +8 -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 +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
- 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/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +60 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +47 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +31 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +14 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +29 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +29 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
- 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-share.mjs +30 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +40 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +16 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +99 -14
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +32 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
- 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_share.py +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +302 -22
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +54 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +10 -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
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
8
8
|
import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabric-base.mjs';
|
|
9
|
-
import { atomicWrite,
|
|
9
|
+
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
12
|
|
|
@@ -370,14 +370,15 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
370
370
|
/**
|
|
371
371
|
* Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
|
|
372
372
|
* override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
|
|
373
|
-
* fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go
|
|
373
|
+
* fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go, and `instance` which
|
|
374
|
+
* instance's, so one context's purge never reaches another's, FAA-125). A job still
|
|
374
375
|
* loaded at `timeout` seconds throws, leaving the plist and the data in place.
|
|
375
376
|
*/
|
|
376
|
-
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
|
|
377
|
+
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, instance = 'default', run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
|
|
377
378
|
const domain = launchdDomain(platform, uid);
|
|
378
379
|
launchdLabel(label);
|
|
379
380
|
if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
|
|
380
|
-
const dirs = purge ?
|
|
381
|
+
const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
|
|
381
382
|
const opts = { run, platform, uid };
|
|
382
383
|
const target = `${domain}/${label}`;
|
|
383
384
|
run(['launchctl', 'bootout', target]);
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The reading side of an A2A Agent Card (DEC-0122): the well-known document's `surfaces.a2a` member, read as the
|
|
2
|
+
contract's schema reads it (`a2a_surface_findings`, `a2a_view`; `a2a_surface` builds one), and what a public card must
|
|
3
|
+
not carry (`agent_card_findings`): no member about the running process, no secret, no `securitySchemes` entry naming
|
|
4
|
+
the descriptor's token header, every Fabric extension `required: false` (or absent, which A2A reads as false), and interface URLs where the placement puts
|
|
5
|
+
them. Serving a card, the A2A binding and a client are not here (FAA-10). Part of the service kit (layer L3).
|
|
6
|
+
Standard library only."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import sys as _sys # noqa: E402
|
|
13
|
+
from pathlib import Path as _Path # noqa: E402
|
|
14
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
15
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
16
|
+
|
|
17
|
+
from fabric_base import ServiceError # noqa: E402
|
|
18
|
+
from fabric_jsvalue import js_keys # noqa: E402
|
|
19
|
+
from fabric_secrets import secret_value # noqa: E402
|
|
20
|
+
|
|
21
|
+
#: A2A 1.0 section 8.2: where a peer looks for the public card.
|
|
22
|
+
A2A_SURFACE_PATH = "/.well-known/agent-card.json"
|
|
23
|
+
#: The A2A version the contract pins (the A2A peer profile); `surfaces.a2a.protocolVersion` is this constant.
|
|
24
|
+
A2A_PROTOCOL_VERSION = "1.0"
|
|
25
|
+
#: The contract's extension namespace (decision D6): an extension whose uri starts here is a Fabric fact.
|
|
26
|
+
FABRIC_EXTENSION_PREFIX = "https://fabric.passioncode.ai/"
|
|
27
|
+
#: The well-known document's members about the running process, which a public card never carries (DEC-0122 item 2).
|
|
28
|
+
PROCESS_MEMBERS = ("build", "process", "pid", "status", "summary", "degraded")
|
|
29
|
+
MAX_PATH = 2048 # service-common.schema.json originPath maxLength, counted in code points as ajv counts it
|
|
30
|
+
|
|
31
|
+
# JavaScript's \s (the schema's pattern runs under ajv), not Python's: U+FEFF is one, U+001C and U+0085 are not.
|
|
32
|
+
_JS_SPACE = ("\t\n\x0b\x0c\r "
|
|
33
|
+
"
")
|
|
34
|
+
_ORIGIN_PATH = re.compile("^/(?!/)[^%s]*\\Z" % _JS_SPACE)
|
|
35
|
+
_HTTPS = re.compile(r"^https://", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pointer(*parts: Any) -> str:
|
|
39
|
+
return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ascii_lower(text: str) -> str:
|
|
43
|
+
return re.sub("[A-Z]", lambda m: m.group(0).lower(), text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _finding(path: str, keyword: str, message: str) -> Dict[str, str]:
|
|
47
|
+
return {"instancePath": path, "keyword": keyword, "message": message}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def a2a_surface_findings(value: Any, base: str = "/surfaces/a2a") -> List[Dict[str, str]]:
|
|
51
|
+
"""`surfaces.a2a` against service-well-known.schema.json at the pin, in ajv's order: each finding's instancePath
|
|
52
|
+
and keyword are the schema error's (test/fixtures/contract/a2a-vectors.json), with a sentence."""
|
|
53
|
+
if not isinstance(value, dict):
|
|
54
|
+
return [_finding(base, "type", "surfaces.a2a is an object {path, protocolVersion}")]
|
|
55
|
+
found = [_finding(base, "required", "surfaces.a2a has no %s" % k) for k in ("path", "protocolVersion") if k not in value]
|
|
56
|
+
found += [_finding(base, "additionalProperties", "surfaces.a2a has no member %s: the card names its interfaces, "
|
|
57
|
+
"bindings and security schemes (DEC-0122)" % k)
|
|
58
|
+
for k in value if k not in ("path", "protocolVersion")]
|
|
59
|
+
if "path" in value:
|
|
60
|
+
path = value["path"]
|
|
61
|
+
if not isinstance(path, str):
|
|
62
|
+
found.append(_finding(base + "/path", "type", "surfaces.a2a.path is a string"))
|
|
63
|
+
else:
|
|
64
|
+
if len(path) > MAX_PATH:
|
|
65
|
+
found.append(_finding(base + "/path", "maxLength", "surfaces.a2a.path is longer than %d characters" % MAX_PATH))
|
|
66
|
+
if not _ORIGIN_PATH.match(path):
|
|
67
|
+
found.append(_finding(base + "/path", "pattern", "surfaces.a2a.path is not a path on the service origin "
|
|
68
|
+
"(one leading /, no whitespace)"))
|
|
69
|
+
if "protocolVersion" in value and not (isinstance(value["protocolVersion"], str) and value["protocolVersion"] == A2A_PROTOCOL_VERSION):
|
|
70
|
+
found.append(_finding(base + "/protocolVersion", "const", "surfaces.a2a.protocolVersion is %s, the A2A version the "
|
|
71
|
+
"contract pins" % A2A_PROTOCOL_VERSION))
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def a2a_surface_problems(value: Any) -> List[str]:
|
|
76
|
+
"""The findings of a2a_surface_findings as sentences; empty when the member is valid."""
|
|
77
|
+
return [f["message"] for f in a2a_surface_findings(value)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def a2a_surface(path: str = A2A_SURFACE_PATH) -> Dict[str, str]:
|
|
81
|
+
"""The `surfaces.a2a` member for a service that serves its public card at `path` (DEC-0122 item 1)."""
|
|
82
|
+
surface = {"path": path, "protocolVersion": A2A_PROTOCOL_VERSION}
|
|
83
|
+
problems = a2a_surface_problems(surface)
|
|
84
|
+
if problems:
|
|
85
|
+
raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
|
|
86
|
+
return surface
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def a2a_view(wk: Any) -> Dict[str, Any]:
|
|
90
|
+
"""What a host reads of a well-known document's A2A member; never raises. `declared` is whether `surfaces.a2a`
|
|
91
|
+
is present, `valid` whether it is the schema's, `path` the card's path when it is, `findings` the schema's."""
|
|
92
|
+
surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
|
|
93
|
+
if not isinstance(surfaces, dict) or "a2a" not in surfaces:
|
|
94
|
+
return {"declared": False, "valid": False, "path": None, "protocolVersion": None, "findings": []}
|
|
95
|
+
findings = a2a_surface_findings(surfaces["a2a"])
|
|
96
|
+
valid = not findings
|
|
97
|
+
return {"declared": True, "valid": valid, "path": surfaces["a2a"]["path"] if valid else None,
|
|
98
|
+
"protocolVersion": A2A_PROTOCOL_VERSION if valid else None, "findings": findings}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _card(code: str, path: str, message: str) -> Dict[str, str]:
|
|
102
|
+
return {"code": code, "instancePath": path, "message": message}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _secrets(card: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
106
|
+
"""Every string the card holds that is shaped like a credential, by its place; walked without recursion, in the
|
|
107
|
+
order JavaScript lists keys, so both twins name the same places in the same order."""
|
|
108
|
+
found: List[Dict[str, str]] = []
|
|
109
|
+
stack: List[Any] = [(card, "")]
|
|
110
|
+
while stack:
|
|
111
|
+
node, path = stack.pop()
|
|
112
|
+
if isinstance(node, str):
|
|
113
|
+
if secret_value(node):
|
|
114
|
+
found.append(_card("secret", path, "%s holds a value shaped like a credential: a public card carries no "
|
|
115
|
+
"secret (DEC-0122 item 2)" % (path or "the card")))
|
|
116
|
+
elif isinstance(node, dict):
|
|
117
|
+
stack.extend((node[k], path + _pointer(k)) for k in reversed(js_keys(node)))
|
|
118
|
+
elif isinstance(node, list):
|
|
119
|
+
stack.extend((node[i], path + _pointer(i)) for i in reversed(range(len(node))))
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _descriptor_schemes(card: Dict[str, Any], header: str) -> List[Dict[str, str]]:
|
|
124
|
+
"""D5: a securitySchemes entry that is an API key in the descriptor's own header. With Authorization (the
|
|
125
|
+
default) the card cannot show the token, so nothing is reported."""
|
|
126
|
+
schemes = card.get("securitySchemes")
|
|
127
|
+
if not isinstance(schemes, dict) or not header or _ascii_lower(header) == "authorization":
|
|
128
|
+
return []
|
|
129
|
+
found = []
|
|
130
|
+
for name in js_keys(schemes):
|
|
131
|
+
scheme = schemes[name]
|
|
132
|
+
if not isinstance(scheme, dict):
|
|
133
|
+
continue
|
|
134
|
+
key = scheme.get("apiKeySecurityScheme") # A2A 1.0: one wrapper member per kind
|
|
135
|
+
if isinstance(key, dict):
|
|
136
|
+
location, named = key.get("location"), key.get("name")
|
|
137
|
+
elif scheme.get("type") == "apiKey": # the OpenAPI shape of A2A before 1.0
|
|
138
|
+
location, named = scheme.get("in"), scheme.get("name")
|
|
139
|
+
else:
|
|
140
|
+
continue
|
|
141
|
+
if location == "header" and isinstance(named, str) and _ascii_lower(named) == _ascii_lower(header):
|
|
142
|
+
found.append(_card("descriptor-token-scheme", _pointer("securitySchemes", name),
|
|
143
|
+
"securitySchemes.%s is an API key in %s, the descriptor's token header: an A2A interface "
|
|
144
|
+
"never takes the descriptor's token (DEC-0122 item 3)" % (name, header)))
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _fabric_extensions(card: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
149
|
+
"""D6: an extension under the contract's namespace is not required (A2A 1.0 section 4.4.4): `required` false or
|
|
150
|
+
absent, which A2A reads as false (a protobuf-written card drops a false default); true or a non-boolean is a finding."""
|
|
151
|
+
capabilities = card.get("capabilities")
|
|
152
|
+
extensions = capabilities.get("extensions") if isinstance(capabilities, dict) else None
|
|
153
|
+
if not isinstance(extensions, list):
|
|
154
|
+
return []
|
|
155
|
+
found = []
|
|
156
|
+
for i, extension in enumerate(extensions):
|
|
157
|
+
uri = extension.get("uri") if isinstance(extension, dict) else None
|
|
158
|
+
if not isinstance(uri, str) or _ascii_lower(uri[:len(FABRIC_EXTENSION_PREFIX)]) != FABRIC_EXTENSION_PREFIX:
|
|
159
|
+
continue
|
|
160
|
+
if "required" in extension and extension["required"] is not False:
|
|
161
|
+
found.append(_card("fabric-extension-required", _pointer("capabilities", "extensions", i),
|
|
162
|
+
"the Fabric extension at capabilities.extensions[%d] is required, so a peer that does not "
|
|
163
|
+
"know it cannot call; it carries required: false (DEC-0122 item 4)" % i))
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _interfaces(card: Dict[str, Any], origin: Optional[str], remote: bool) -> List[Dict[str, str]]:
|
|
168
|
+
"""Where the card's interfaces are (DEC-0122 item 2): on a local placement on the service's loopback origin as
|
|
169
|
+
written, on a remote one https; every one A2A 1.0."""
|
|
170
|
+
interfaces = card.get("supportedInterfaces")
|
|
171
|
+
if not isinstance(interfaces, list) or not interfaces:
|
|
172
|
+
return [_card("interfaces-missing", "/supportedInterfaces", "the card names no supportedInterfaces: A2A 1.0 "
|
|
173
|
+
"lists a card's interfaces there")]
|
|
174
|
+
found = []
|
|
175
|
+
for i, interface in enumerate(interfaces):
|
|
176
|
+
place = _pointer("supportedInterfaces", i)
|
|
177
|
+
url = interface.get("url") if isinstance(interface, dict) else None
|
|
178
|
+
if not isinstance(url, str):
|
|
179
|
+
found.append(_card("interface-shape", place, "supportedInterfaces[%d] is not an interface with a url" % i))
|
|
180
|
+
continue
|
|
181
|
+
if remote:
|
|
182
|
+
if not _HTTPS.match(url):
|
|
183
|
+
found.append(_card("interface-scheme", place + "/url", "supportedInterfaces[%d].url is not https: a "
|
|
184
|
+
"loopback or plain-http interface is admitted on a local placement only" % i))
|
|
185
|
+
elif origin and not (url == origin or url.startswith(origin + "/")):
|
|
186
|
+
found.append(_card("interface-origin", place + "/url", "supportedInterfaces[%d].url is not on the service "
|
|
187
|
+
"origin %s, where a peer on this computer reaches it" % (i, origin)))
|
|
188
|
+
if not (isinstance(interface.get("protocolVersion"), str) and interface["protocolVersion"] == A2A_PROTOCOL_VERSION):
|
|
189
|
+
found.append(_card("interface-version", place + "/protocolVersion", "supportedInterfaces[%d] does not expose "
|
|
190
|
+
"A2A %s, the version surfaces.a2a names" % (i, A2A_PROTOCOL_VERSION)))
|
|
191
|
+
return found
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def agent_card_findings(card: Any, *, origin: Optional[str] = None, remote: bool = False,
|
|
195
|
+
descriptor_header: Optional[str] = "Authorization") -> List[Dict[str, str]]:
|
|
196
|
+
"""What a public Agent Card must not carry or say (DEC-0122 items 2 to 4; decisions D5, D6), each finding
|
|
197
|
+
{code, instancePath, message} naming the place and never the value. `origin` is the service origin the card was
|
|
198
|
+
read on (its interfaces' place on a local placement; omitted, the place is not judged), `remote` the placement,
|
|
199
|
+
`descriptor_header` the descriptor's `auth.header` (default Authorization). It reads the card only: whether an
|
|
200
|
+
interface accepts the token cannot be seen without sending it, which DEC-0122 forbids."""
|
|
201
|
+
if not isinstance(card, dict):
|
|
202
|
+
return [_card("card-shape", "", "the card is not a JSON object")]
|
|
203
|
+
found = [_card("process-member", _pointer(k), "the card carries %s, a member about the running process: a public "
|
|
204
|
+
"card carries none (DEC-0122 item 2)" % k) for k in PROCESS_MEMBERS if k in card]
|
|
205
|
+
found += _secrets(card)
|
|
206
|
+
found += _descriptor_schemes(card, descriptor_header if isinstance(descriptor_header, str) else "")
|
|
207
|
+
found += _fabric_extensions(card)
|
|
208
|
+
found += _interfaces(card, origin, remote)
|
|
209
|
+
return found
|
|
@@ -14,6 +14,8 @@ extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L
|
|
|
14
14
|
refused when ``decision.schema.json`` would refuse it.
|
|
15
15
|
- ``action_caller(tier, authenticated)``: the caller a service records (AP-2): the identity it authenticated, else
|
|
16
16
|
``local:T0`` / ``local:T1`` for a caller its local floor authenticated, never a name the call carries.
|
|
17
|
+
- DEC-0125: ``action_decision`` reads a call's mandate (``_meta.mandate``) through fabric_mandate.mandate_covers when the
|
|
18
|
+
call carries no decision.
|
|
17
19
|
|
|
18
20
|
The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
|
|
19
21
|
patterns over the whole string, as the contract's validator reads them. Never raises on what it reads. Standard library
|
|
@@ -32,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
32
34
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
33
35
|
|
|
34
36
|
from fabric_base import ServiceError # noqa: E402
|
|
35
|
-
from fabric_jsvalue import MISSING, contains, get, json_equal, json_stringify # noqa: E402
|
|
37
|
+
from fabric_jsvalue import MISSING, contains, get, is_number, json_equal, json_stringify # noqa: E402
|
|
38
|
+
from fabric_mandate import mandate_covers # noqa: E402 - DEC-0125
|
|
36
39
|
from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
|
|
37
40
|
|
|
38
41
|
ACTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1"
|
|
@@ -152,7 +155,13 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
|
|
|
152
155
|
digest; an accepted elicitation is the operator's; no decision is `operator-decision-required`; a decision that
|
|
153
156
|
is not the schema's, is for another action or object, or whose `at` is more than 60 s ahead of `now` or more
|
|
154
157
|
than 24 h behind it is `decision-invalid`; otherwise the operator's act, recorded with `caller`.
|
|
155
|
-
-
|
|
158
|
+
- DEC-0125: a call with no decision that carries `mandate` (its `_meta.mandate`) is answered as
|
|
159
|
+
fabric_mandate.mandate_covers answers, given `self` (the service's own agent id), `callerAgent` (only an agent id
|
|
160
|
+
the service authenticated), `effect` (the action's effect class, the service's own knowledge), `spendUsd` (its
|
|
161
|
+
own estimate) and `project`: covered, `{by: operator, via: mandate, task, chain, caller}`; otherwise
|
|
162
|
+
`operator-decision-required` or `decision-invalid` with `data.detail`.
|
|
163
|
+
- `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing,
|
|
164
|
+
self-missing) is the service's
|
|
156
165
|
own: an input it owes is missing or unreadable. It is never sent to a caller.
|
|
157
166
|
"""
|
|
158
167
|
action = get(inputs, "action")
|
|
@@ -182,6 +191,8 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
|
|
|
182
191
|
if math.isnan(clock):
|
|
183
192
|
return {"fault": "now-unreadable"}
|
|
184
193
|
decision = get(inputs, "decision")
|
|
194
|
+
if decision is MISSING and get(inputs, "mandate") is not MISSING:
|
|
195
|
+
return _by_mandate(inputs, action, caller)
|
|
185
196
|
if decision is MISSING:
|
|
186
197
|
return _refuse(OPERATOR_DECISION_REQUIRED)
|
|
187
198
|
if not decision_shape(decision):
|
|
@@ -195,6 +206,33 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
|
|
|
195
206
|
return {"ok": True, "recordAs": {"by": "operator", "via": "relayed-instruction", "caller": caller}}
|
|
196
207
|
|
|
197
208
|
|
|
209
|
+
def _by_mandate(inputs: Any, action: Dict[str, Any], caller: str) -> Dict[str, Any]:
|
|
210
|
+
"""A relayed action's call that carries a mandate and no decision (DEC-0125 MD-3): the mandate decides, as the
|
|
211
|
+
contract's byMandate reads it: the service's own `effect` and agent id `self` are required (else a fault), its
|
|
212
|
+
estimate `spendUsd` is read when a number and `project` when a string."""
|
|
213
|
+
effect, me = get(inputs, "effect"), get(inputs, "self")
|
|
214
|
+
if not isinstance(effect, str):
|
|
215
|
+
return {"fault": "effect-missing"}
|
|
216
|
+
if not isinstance(me, str):
|
|
217
|
+
return {"fault": "self-missing"}
|
|
218
|
+
call: Dict[str, Any] = {"capability": action["tool"], "action": action["id"], "effect": effect}
|
|
219
|
+
spend, project = get(inputs, "spendUsd"), get(inputs, "project")
|
|
220
|
+
if is_number(spend):
|
|
221
|
+
call["spendUsd"] = spend
|
|
222
|
+
if isinstance(project, str):
|
|
223
|
+
call["project"] = project
|
|
224
|
+
covers: Dict[str, Any] = {"mandate": inputs["mandate"], "tier": get(inputs, "tier"), "now": get(inputs, "now"),
|
|
225
|
+
"self": me, "call": call}
|
|
226
|
+
if get(inputs, "callerAgent") is not MISSING:
|
|
227
|
+
covers["callerAgent"] = inputs["callerAgent"]
|
|
228
|
+
answer = mandate_covers(covers)
|
|
229
|
+
if "recordAs" in answer:
|
|
230
|
+
record = dict(answer["recordAs"])
|
|
231
|
+
record["caller"] = caller
|
|
232
|
+
return {"ok": True, "recordAs": record}
|
|
233
|
+
return answer # never `ignored`: the tier was read first, so a T2 call answered approval-required before (MD-5)
|
|
234
|
+
|
|
235
|
+
|
|
198
236
|
def actions_view(well_known: Any) -> Dict[str, Any]:
|
|
199
237
|
"""The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: `{declared: False}`,
|
|
200
238
|
`{declared: True, valid: False, errors}` or `{declared: True, valid: True, actions}`. Never raises."""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""One instance per context (FAA-125, organization row N-11; contract DEC-0082, "One instance per context on a machine"):
|
|
2
|
+
from a context, ``personal`` or ``org:<orgId>``, the service instance, its folders, descriptor and instance lock. Part of
|
|
3
|
+
the service kit (layer L3, after fabric_descriptor); the twin of fabric-context.mjs. Standard library only.
|
|
4
|
+
|
|
5
|
+
A service started for a context runs as that context's one instance on this machine: its own instance name, descriptor,
|
|
6
|
+
data, log and cache folders and instance lock; a second copy for the same context exits 75 as ``hold_single_instance``
|
|
7
|
+
does; an organization's instance runs only under a placement of this machine's estate record that names an unrevoked
|
|
8
|
+
share grant for that context (FAC-SEM-111), inside the grant's window; a data folder never changes context (its
|
|
9
|
+
``fabric-context.json`` marker). A refusal exits 78 (EX_CONFIG) with nothing created. The facade ``fabric_service`` does
|
|
10
|
+
not re-export this module, so a service that does not run per context keeps the estate reader out of its closure:
|
|
11
|
+
``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts)."""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
import sys as _sys # noqa: E402
|
|
23
|
+
from pathlib import Path as _Path # noqa: E402
|
|
24
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
25
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
26
|
+
from fabric_base import ServiceError, _INSTANCE # noqa: E402
|
|
27
|
+
from fabric_descriptor import descriptor_path # noqa: E402
|
|
28
|
+
from fabric_estate import parse_context # noqa: E402
|
|
29
|
+
from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
|
|
30
|
+
from fabric_paths import atomic_write, instance_dirs # noqa: E402
|
|
31
|
+
from fabric_share import instance_share_grant # noqa: E402
|
|
32
|
+
from fabric_time import clock_ms # noqa: E402
|
|
33
|
+
|
|
34
|
+
#: EX_CONFIG: the context is refused; 75 stays "already running" (``fabric_lock.EXIT_ALREADY_RUNNING``).
|
|
35
|
+
EXIT_CONTEXT_REFUSED = 78
|
|
36
|
+
CONTEXT_MARKER = "fabric-context.json"
|
|
37
|
+
#: An organization id kept readable in the instance name: lower-case words joined by single hyphens (brief D15).
|
|
38
|
+
_READABLE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
|
|
39
|
+
#: Only a hashed name ends in a hyphen and 10 hex digits, so a readable and a hashed name never meet.
|
|
40
|
+
_HASHED_TAIL = re.compile(r"-[0-9a-f]{10}\Z")
|
|
41
|
+
_READABLE_MAX = 28
|
|
42
|
+
_SLUG_MAX = 17
|
|
43
|
+
#: What each refusal tells the person; the sentence never carries a record or grant value.
|
|
44
|
+
_REASONS = {
|
|
45
|
+
"context-invalid": "the context is neither personal nor org:<orgId> as written",
|
|
46
|
+
"record-required": "an organization's instance needs this machine's estate record, its machine id and its agentRef",
|
|
47
|
+
"placement-missing": "the estate record places no such agent on this machine in that context",
|
|
48
|
+
"grant-refused": "its placement names no unrevoked share grant for that context (FAC-SEM-111)",
|
|
49
|
+
"grant-window": "now is outside its share grant's window",
|
|
50
|
+
"context-mismatch": "its data folder belongs to another context or instance (%s)" % CONTEXT_MARKER,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ContextRefused(ServiceError):
|
|
55
|
+
"""A context this machine does not run. ``code``: context-invalid, record-required, placement-missing,
|
|
56
|
+
grant-refused, grant-window or context-mismatch."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, code: str, detail: str = ""):
|
|
59
|
+
self.code = code
|
|
60
|
+
self.detail = detail
|
|
61
|
+
super().__init__("%s: %s." % (code, detail or _REASONS[code]))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _shown(context: Any) -> str:
|
|
65
|
+
return context if parse_context(context) else "a context that is neither personal nor org:<orgId>"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def context_instance_name(context: Any, *, personal_instance: str = "default") -> str:
|
|
69
|
+
"""The instance name of ``context`` (brief D15): ``personal`` is ``personal_instance`` (``default``, so an existing
|
|
70
|
+
install keeps its descriptor, folders and lock); ``org:<orgId>`` is ``org-<orgId>`` when the id is lower-case words
|
|
71
|
+
joined by single hyphens, at most 28 characters, and does not end in a hyphen and 10 hex digits; otherwise
|
|
72
|
+
``org-<slug>-<first 10 hex digits of sha256(orgId)>``. Every name is a descriptor's instance. Raises ContextRefused."""
|
|
73
|
+
parsed = parse_context(context)
|
|
74
|
+
if parsed is None:
|
|
75
|
+
raise ContextRefused("context-invalid")
|
|
76
|
+
if parsed["kind"] == "personal":
|
|
77
|
+
if not isinstance(personal_instance, str) or not _INSTANCE.match(personal_instance) or personal_instance.startswith("org-"):
|
|
78
|
+
raise ContextRefused("context-invalid", "the personal instance %r is no instance name outside org-" % (personal_instance,))
|
|
79
|
+
return personal_instance
|
|
80
|
+
org = parsed["org"]
|
|
81
|
+
if _READABLE.match(org) and len(org) <= _READABLE_MAX and not _HASHED_TAIL.search(org):
|
|
82
|
+
return "org-" + org
|
|
83
|
+
slug = re.sub(r"[^a-z0-9]+", "-", org.lower()).strip("-")[:_SLUG_MAX].strip("-") or "x"
|
|
84
|
+
return "org-%s-%s" % (slug, hashlib.sha256(org.encode("utf-8")).hexdigest()[:10])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str, Any]] = None,
|
|
88
|
+
machine_id: Optional[str] = None, agent_ref: Optional[str] = None, at: Any = None,
|
|
89
|
+
personal_instance: str = "default", services_directory: Optional[Path] = None) -> Dict[str, Any]:
|
|
90
|
+
"""What ``context`` runs as on this machine; pure (no folder, file or lock). Answers ``{"context", "kind":
|
|
91
|
+
"personal"|"org", "org"?, "instance", "key": "<id>.<instance>", "dirs": {"data", "logs", "cache"},
|
|
92
|
+
"descriptorPath", "grant"?: {"id", "startsAt"?, "expiresAt"?}}``, never the record. Raises ContextRefused.
|
|
93
|
+
|
|
94
|
+
``record`` is the estate record revision this machine applied, already verified (``verify_estate_record``); the
|
|
95
|
+
helper reads it and verifies no signature. ``machine_id`` and ``agent_ref`` come from whoever installed the
|
|
96
|
+
instance, never guessed. ``at`` is the clock (an aware datetime or epoch seconds; now when None)."""
|
|
97
|
+
instance = context_instance_name(context, personal_instance=personal_instance)
|
|
98
|
+
parsed = parse_context(context)
|
|
99
|
+
dirs = instance_dirs(service_id, instance)
|
|
100
|
+
answer: Dict[str, Any] = {"context": context, "kind": parsed["kind"], "instance": instance,
|
|
101
|
+
"key": "%s.%s" % (service_id, instance), "dirs": dirs,
|
|
102
|
+
"descriptorPath": descriptor_path(service_id, instance, services_directory)}
|
|
103
|
+
have_place = isinstance(record, dict) and isinstance(machine_id, str) and isinstance(agent_ref, str)
|
|
104
|
+
at_ms = time.time() * 1000.0 if at is None else clock_ms(at)
|
|
105
|
+
if parsed["kind"] == "personal":
|
|
106
|
+
if have_place: # brief D20: a personal placement that names a grant is refused; no placement is no refusal
|
|
107
|
+
checked = instance_share_grant(record, machine_id, agent_ref, context, at_ms)
|
|
108
|
+
if not checked["ok"] and checked["code"] == "grant-refused":
|
|
109
|
+
raise ContextRefused("grant-refused", checked["detail"])
|
|
110
|
+
return answer
|
|
111
|
+
answer["org"] = parsed["org"]
|
|
112
|
+
if not have_place:
|
|
113
|
+
raise ContextRefused("record-required")
|
|
114
|
+
checked = instance_share_grant(record, machine_id, agent_ref, context, at_ms)
|
|
115
|
+
if not checked["ok"]:
|
|
116
|
+
raise ContextRefused(checked["code"], checked["detail"])
|
|
117
|
+
answer["grant"] = checked["grant"]
|
|
118
|
+
return answer
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _refuse(service_id: Any, context: Any, code: str) -> None:
|
|
122
|
+
print("%s will not run for %s (%s): %s." % (service_id, _shown(context), code, _REASONS[code]), file=sys.stderr, flush=True)
|
|
123
|
+
raise SystemExit(EXIT_CONTEXT_REFUSED)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
|
|
127
|
+
"""True when the marker names this instance, False when it names another or cannot be read, None when absent."""
|
|
128
|
+
try:
|
|
129
|
+
text = path.read_text(encoding="utf-8")
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
return None
|
|
132
|
+
except OSError:
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
found = json.loads(text)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
return isinstance(found, dict) and all(found.get(key) == value for key, value in expected.items())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dict[str, Any]] = None,
|
|
142
|
+
machine_id: Optional[str] = None, agent_ref: Optional[str] = None, at: Any = None,
|
|
143
|
+
personal_instance: str = "default", wait_seconds: Optional[float] = None,
|
|
144
|
+
sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
|
|
145
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
146
|
+
services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
|
|
147
|
+
"""Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
|
|
148
|
+
sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
|
|
149
|
+
folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
|
|
150
|
+
is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
|
|
151
|
+
exits 78 (``context-mismatch``). Returns the instance and the held lock."""
|
|
152
|
+
try:
|
|
153
|
+
info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
|
|
154
|
+
personal_instance=personal_instance, services_directory=services_directory)
|
|
155
|
+
except ContextRefused as refused:
|
|
156
|
+
_refuse(service_id, context, refused.code)
|
|
157
|
+
lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
158
|
+
expected = {"service": service_id, "instance": info["instance"], "context": context}
|
|
159
|
+
marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
|
|
160
|
+
try:
|
|
161
|
+
matches = _marker_matches(marker, expected)
|
|
162
|
+
if matches is None:
|
|
163
|
+
atomic_write(marker, (json.dumps(expected, indent=1) + "\n").encode("utf-8"), mode=0o600)
|
|
164
|
+
except BaseException:
|
|
165
|
+
lock.release()
|
|
166
|
+
raise
|
|
167
|
+
if matches is False:
|
|
168
|
+
lock.release()
|
|
169
|
+
_refuse(service_id, context, "context-mismatch")
|
|
170
|
+
return info, lock
|
|
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
|
|
|
45
45
|
jcs,
|
|
46
46
|
)
|
|
47
47
|
from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
|
|
48
|
+
from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
|
|
48
49
|
|
|
49
50
|
REFUSAL_CODES = (
|
|
50
51
|
"unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
|
|
@@ -93,7 +94,11 @@ REFUSAL_MESSAGES = {
|
|
|
93
94
|
"issuer-unavailable": "The estate's published keys cannot be read.",
|
|
94
95
|
"unknown-state": "The state cannot be established, so the call is refused.",
|
|
95
96
|
# DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
|
|
96
|
-
"operator-decision-required": "This action is the operator's decision; ask the operator and call again with it in
|
|
97
|
+
"operator-decision-required": ("This action is the operator's decision; ask the operator and call again with it in "
|
|
98
|
+
"_meta.decision. A client that cannot set _meta: Fabric's agent.call with "
|
|
99
|
+
"call.decision, once the hub forwards it; else this service's own elicitation, "
|
|
100
|
+
"where the client shows one; else the action's page handed to the operator as "
|
|
101
|
+
"Fabric Dashboards' open_link."),
|
|
97
102
|
"decision-invalid": "The call's decision is not valid for this action, object or time.",
|
|
98
103
|
"human-step-required": "This step is the person's own; hand them the link in error.data.url.",
|
|
99
104
|
}
|
|
@@ -299,16 +304,6 @@ def _chain_of(act: Dict[str, Any]) -> List[str]:
|
|
|
299
304
|
return chain
|
|
300
305
|
|
|
301
306
|
|
|
302
|
-
def effect_closure(ceiling: List[str]) -> set:
|
|
303
|
-
"""`none` is below every class and `draft` below every external one; external classes are not ordered."""
|
|
304
|
-
admitted = set(ceiling)
|
|
305
|
-
if ceiling:
|
|
306
|
-
admitted.add("none")
|
|
307
|
-
if any(effect != "none" for effect in ceiling):
|
|
308
|
-
admitted.add("draft")
|
|
309
|
-
return admitted
|
|
310
|
-
|
|
311
|
-
|
|
312
307
|
def effect_within(effect: str, ceiling: List[str]) -> bool:
|
|
313
308
|
return effect in effect_closure(ceiling)
|
|
314
309
|
|
|
@@ -822,11 +817,19 @@ def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
|
|
|
822
817
|
return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
|
|
823
818
|
|
|
824
819
|
|
|
820
|
+
#: DEC-0125 MD-4: a refusal outside a mandate goes back up the chain; only the leading agent asks the operator.
|
|
821
|
+
OUTSIDE_MANDATE_MESSAGE = ("Outside the operator's mandate for this call. Call again within your own mandate if it covers this part; else pass this refusal to the agent that called you. Only the leading agent asks the operator, once, about this part alone, then relays a decision or mandate that covers it: in _meta, or, without _meta, through Fabric's agent.call once the hub forwards it, the service's elicitation, or the action's page as a Fabric Dashboards open_link (DEC-0125 MD-4).")
|
|
822
|
+
|
|
823
|
+
|
|
825
824
|
def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
|
|
826
825
|
"""The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
|
|
827
826
|
data = {"detail": refusal["detail"]}
|
|
828
827
|
data.update(refusal.get("data") or {})
|
|
829
|
-
|
|
828
|
+
# the gate's answer keeps no-decision at the top and its outside-mandate part in data.detail; only a string names a part
|
|
829
|
+
part = data.get("detail") if isinstance(data.get("detail"), str) else refusal["detail"]
|
|
830
|
+
outside = refusal["code"] == "operator-decision-required" and isinstance(part, str) and part.startswith("outside-mandate:")
|
|
831
|
+
message = OUTSIDE_MANDATE_MESSAGE if outside else REFUSAL_MESSAGES.get(refusal["code"], "Refused.")
|
|
832
|
+
return {"error": {"code": refusal["code"], "message": message, "data": data}}
|
|
830
833
|
|
|
831
834
|
|
|
832
835
|
def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -37,6 +37,7 @@ from fabric_protocols import (
|
|
|
37
37
|
)
|
|
38
38
|
from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
|
|
39
39
|
from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
|
|
40
|
+
from fabric_a2a import a2a_surface_problems # noqa: E402 - DEC-0122
|
|
40
41
|
from fabric_usage import (
|
|
41
42
|
ROUTES,
|
|
42
43
|
)
|
|
@@ -579,7 +580,8 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
579
580
|
extensions: Optional[Dict[str, Any]] = None,
|
|
580
581
|
configuration: Optional[Dict[str, Any]] = None,
|
|
581
582
|
protocols: Optional[Sequence[str]] = None,
|
|
582
|
-
actions: Optional[Sequence[Dict[str, Any]]] = None
|
|
583
|
+
actions: Optional[Sequence[Dict[str, Any]]] = None,
|
|
584
|
+
a2a: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
583
585
|
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
584
586
|
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
585
587
|
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
@@ -587,7 +589,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
587
589
|
`actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
|
|
588
590
|
tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
|
|
589
591
|
A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
|
|
590
|
-
humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list.
|
|
592
|
+
humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list.
|
|
593
|
+
`a2a` (DEC-0122) is `surfaces.a2a`, where the service serves its public Agent Card (fabric_a2a.a2a_surface builds
|
|
594
|
+
one); passed here or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before
|
|
595
|
+
DEC-0122 refuses the member, so a service carries it only once its readers are pinned at or after it."""
|
|
591
596
|
if status not in STATUSES:
|
|
592
597
|
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
593
598
|
degraded = list(degraded)
|
|
@@ -595,6 +600,15 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
595
600
|
status = "degraded"
|
|
596
601
|
if "events" not in surfaces:
|
|
597
602
|
raise ServiceError("surfaces.events is required.")
|
|
603
|
+
if a2a is not None:
|
|
604
|
+
if "a2a" in surfaces:
|
|
605
|
+
raise ServiceError("surfaces.a2a: pass it in surfaces or as a2a, not both.")
|
|
606
|
+
surfaces = dict(surfaces, a2a=a2a)
|
|
607
|
+
if "a2a" in surfaces:
|
|
608
|
+
# DEC-0122: a host reads the member by the schema; a service never publishes one it would refuse.
|
|
609
|
+
problems = a2a_surface_problems(surfaces["a2a"])
|
|
610
|
+
if problems:
|
|
611
|
+
raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
|
|
598
612
|
doc: Dict[str, Any] = {
|
|
599
613
|
"protocol": PROTOCOL,
|
|
600
614
|
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
@@ -776,6 +776,13 @@ def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any])
|
|
|
776
776
|
return problems
|
|
777
777
|
|
|
778
778
|
|
|
779
|
+
def placement_grant_findings(record: Any, placement: Any) -> List[Dict[str, str]]:
|
|
780
|
+
"""FAC-SEM-111 for one placement of ``record``, as findings (``instancePath`` relative to the placement). Answers,
|
|
781
|
+
never raises: a record or placement that is no object reads as an empty one (FAA-125)."""
|
|
782
|
+
record, placement = (v if isinstance(v, dict) else {} for v in (record, placement))
|
|
783
|
+
return [{"code": "FAC-SEM-111", "instancePath": at, "message": m} for at, m in _placement_grant_problems(record, placement)]
|
|
784
|
+
|
|
785
|
+
|
|
779
786
|
def _placement_grants(value: Dict[str, Any], flag) -> None:
|
|
780
787
|
for machine, m in _indexed(_get(value, "machines")):
|
|
781
788
|
for placement, p in _indexed(_get(machine, "packages")):
|