@passioncode-ai/passioncode 0.2.38 → 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 +16 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/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 +50 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- 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-kit.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -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 +159 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -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 +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
- 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 +67 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
- 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 +275 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
- 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 +68 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -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 +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-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 +340 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
- 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 +242 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
- 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 +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/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 +413 -25
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Every action a person has, its agent tool and who decides it: `fabric-actions/0.1`, the extension keyed
|
|
2
|
+
extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L3); the twin of fabric-actions.mjs.
|
|
3
|
+
|
|
4
|
+
- ``action_decision(inputs)``: whether a service takes a call of one declared action and as whose act it records it,
|
|
5
|
+
exactly as the contract's ``actionDecision`` answers (AP-2, AP-4; DL-17, DL-18, DL-25). A ``fault`` is the
|
|
6
|
+
service's own and is never sent to a caller.
|
|
7
|
+
- ``decision_shape(value)``: a decision as ``decision.schema.json`` reads it, ``at`` read strictly (the contract's
|
|
8
|
+
``strictTime``).
|
|
9
|
+
- ``actions_view(well_known)``: the host reader of the declaration, as the contract's ``actionsView``.
|
|
10
|
+
- ``actions_extension_rules(well_known)`` and ``action_rules(kind, value)``: FAC-SEM-142.
|
|
11
|
+
- ``actions_extension(actions, capabilities)``: the ``extensions`` entry a service declares; it refuses a declaration
|
|
12
|
+
FAC-SEM-142 would find, so a service never publishes one.
|
|
13
|
+
- ``operator_decision(action, instruction, ...)``: the decision an agent relays in ``_meta.decision`` (AP-2, AP-3),
|
|
14
|
+
refused when ``decision.schema.json`` would refuse it.
|
|
15
|
+
- ``action_caller(tier, authenticated)``: the caller a service records (AP-2): the identity it authenticated, else
|
|
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.
|
|
19
|
+
|
|
20
|
+
The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
|
|
21
|
+
patterns over the whole string, as the contract's validator reads them. Never raises on what it reads. Standard library
|
|
22
|
+
only.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
|
|
30
|
+
|
|
31
|
+
import sys as _sys # noqa: E402
|
|
32
|
+
from pathlib import Path as _Path # noqa: E402
|
|
33
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
34
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
35
|
+
|
|
36
|
+
from fabric_base import ServiceError # 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
|
|
39
|
+
from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
|
|
40
|
+
|
|
41
|
+
ACTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1"
|
|
42
|
+
#: The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets.
|
|
43
|
+
OPERATOR_DECISION_REQUIRED = "operator-decision-required"
|
|
44
|
+
DECISION_INVALID = "decision-invalid"
|
|
45
|
+
HUMAN_STEP_REQUIRED = "human-step-required"
|
|
46
|
+
APPROVAL_REQUIRED = "approval-required"
|
|
47
|
+
GATES = ("none", "relayed", "human")
|
|
48
|
+
HUMAN_STEPS = ("sign-in", "consent", "payment", "secret", "spend-above-limit", "passkey")
|
|
49
|
+
MAX_ACTIONS = 128
|
|
50
|
+
#: How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS).
|
|
51
|
+
CLOCK_SKEW_SECONDS = 60
|
|
52
|
+
#: How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051).
|
|
53
|
+
DECISION_LIFETIME_SECONDS = 24 * 60 * 60
|
|
54
|
+
|
|
55
|
+
_REQUEST_KEY = re.compile(r"[A-Za-z0-9_.-]{1,64}", re.ASCII) # interop-common.schema.json#/$defs/requestKey
|
|
56
|
+
_CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}", re.ASCII) # common.schema.json#/$defs/capabilityName
|
|
57
|
+
_SHA256 = re.compile(r"sha256:[0-9a-f]{64}", re.ASCII)
|
|
58
|
+
_STEP_URL = re.compile(r"https://|/") # a prefix, as the contract's /^(https:\/\/|\/)/ tests it
|
|
59
|
+
_ACTION_MEMBERS = ("id", "title", "tool", "gate", "humanStep")
|
|
60
|
+
_DECISION_MEMBERS = ("by", "via", "action", "target", "instruction", "at")
|
|
61
|
+
_KEY_PATH = "/extensions/" + ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _text(value: Any, low: int, high: int) -> bool:
|
|
65
|
+
"""A string of low..high code points, as the validator counts them (Python counts code points)."""
|
|
66
|
+
return isinstance(value, str) and low <= len(value) <= high
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _matches(pattern: "re.Pattern[str]", value: Any, high: int) -> bool:
|
|
70
|
+
return isinstance(value, str) and len(value) <= high and pattern.fullmatch(value) is not None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _non_empty(value: Any) -> bool:
|
|
74
|
+
return isinstance(value, str) and len(value) > 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _one_of(value: Any, choices: Iterable[str]) -> bool:
|
|
78
|
+
return isinstance(value, str) and value in choices
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _action_errors(value: Any, at: str) -> List[str]:
|
|
82
|
+
"""`service-actions-extension.schema.json#/$defs/action`: the errors at `at`, empty for a valid entry."""
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
return ["%s type" % at]
|
|
85
|
+
errors = ["%s/%s additionalProperties" % (at, key) for key in value if key not in _ACTION_MEMBERS]
|
|
86
|
+
errors += ["%s required %s" % (at, key) for key in ("id", "title", "tool", "gate") if key not in value]
|
|
87
|
+
if "id" in value and not _matches(_REQUEST_KEY, value["id"], 64):
|
|
88
|
+
errors.append("%s/id pattern" % at)
|
|
89
|
+
if "title" in value and not _text(value["title"], 1, 80):
|
|
90
|
+
errors.append("%s/title maxLength" % at)
|
|
91
|
+
if "tool" in value and not _matches(_CAPABILITY, value["tool"], 128):
|
|
92
|
+
errors.append("%s/tool pattern" % at)
|
|
93
|
+
if "gate" in value and not _one_of(value["gate"], GATES):
|
|
94
|
+
errors.append("%s/gate enum" % at)
|
|
95
|
+
if "humanStep" in value and not _one_of(value["humanStep"], HUMAN_STEPS):
|
|
96
|
+
errors.append("%s/humanStep enum" % at)
|
|
97
|
+
return errors
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _extension_errors(value: Any) -> List[str]:
|
|
101
|
+
"""`service-actions-extension.schema.json`: the errors, empty for a valid declaration."""
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
return ["/ type"]
|
|
104
|
+
errors = ["/%s additionalProperties" % key for key in value if key != "actions"]
|
|
105
|
+
if "actions" not in value:
|
|
106
|
+
return errors + ["/ required actions"]
|
|
107
|
+
actions = value["actions"]
|
|
108
|
+
if not isinstance(actions, list):
|
|
109
|
+
return errors + ["/actions type"]
|
|
110
|
+
if not 1 <= len(actions) <= MAX_ACTIONS:
|
|
111
|
+
errors.append("/actions %s" % ("minItems" if not actions else "maxItems"))
|
|
112
|
+
for index, action in enumerate(actions):
|
|
113
|
+
errors += _action_errors(action, "/actions/%d" % index)
|
|
114
|
+
return errors
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def readable_action(action: Any) -> bool:
|
|
118
|
+
"""One valid `actions[]` entry whose `humanStep` is present exactly when `gate` is `human`."""
|
|
119
|
+
return not _action_errors(action, "") and (action["gate"] == "human") == ("humanStep" in action)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def decision_shape(value: Any) -> bool:
|
|
123
|
+
"""`decision.schema.json` (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional
|
|
124
|
+
target of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats'
|
|
125
|
+
date-time also admits a space for T and a leap second, which this refuses, decision D5)."""
|
|
126
|
+
if not isinstance(value, dict) or any(key not in _DECISION_MEMBERS for key in value):
|
|
127
|
+
return False
|
|
128
|
+
if any(key not in value for key in ("by", "via", "action", "instruction", "at")):
|
|
129
|
+
return False
|
|
130
|
+
return (isinstance(value["by"], str) and value["by"] == "operator"
|
|
131
|
+
and isinstance(value["via"], str) and value["via"] == "relayed-instruction"
|
|
132
|
+
and _matches(_REQUEST_KEY, value["action"], 64)
|
|
133
|
+
and ("target" not in value or _text(value["target"], 1, 200))
|
|
134
|
+
and _text(value["instruction"], 1, 500)
|
|
135
|
+
and isinstance(value["at"], str) and not math.isnan(parse_rfc3339_ms(value["at"])))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _accepted_approval(approval: Any) -> bool:
|
|
139
|
+
return isinstance(approval, dict) and approval.get("ok") is True and _non_empty(approval.get("approver"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _refuse(code: str) -> Dict[str, Any]:
|
|
143
|
+
return {"ok": False, "code": code}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def action_decision(inputs: Any) -> Dict[str, Any]:
|
|
147
|
+
"""Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4;
|
|
148
|
+
DL-17, DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
|
|
149
|
+
declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
|
|
150
|
+
`decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted
|
|
151
|
+
elicitation), `approval` (verify_approval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
|
|
152
|
+
|
|
153
|
+
- `none`: the caller's own act. `human`: `human-step-required` with the step and its link, at every tier.
|
|
154
|
+
- `relayed`: an accepted approval is the approver's; a T2 caller without one gets `approval-required` with the
|
|
155
|
+
digest; an accepted elicitation is the operator's; no decision is `operator-decision-required`; a decision that
|
|
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
|
|
157
|
+
than 24 h behind it is `decision-invalid`; otherwise the operator's act, recorded with `caller`.
|
|
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
|
|
165
|
+
own: an input it owes is missing or unreadable. It is never sent to a caller.
|
|
166
|
+
"""
|
|
167
|
+
action = get(inputs, "action")
|
|
168
|
+
if not readable_action(action):
|
|
169
|
+
return {"fault": "action-invalid"}
|
|
170
|
+
if action["gate"] == "none":
|
|
171
|
+
return {"ok": True, "recordAs": "caller"}
|
|
172
|
+
if action["gate"] == "human":
|
|
173
|
+
url = get(inputs, "humanStepUrl")
|
|
174
|
+
if not _non_empty(url) or not _STEP_URL.match(url):
|
|
175
|
+
return {"fault": "url-missing"}
|
|
176
|
+
return {"ok": False, "code": HUMAN_STEP_REQUIRED, "data": {"humanStep": action["humanStep"], "url": url}}
|
|
177
|
+
caller = get(inputs, "caller")
|
|
178
|
+
if not _non_empty(caller):
|
|
179
|
+
return {"fault": "caller-missing"}
|
|
180
|
+
approval = get(inputs, "approval")
|
|
181
|
+
if _accepted_approval(approval):
|
|
182
|
+
return {"ok": True, "recordAs": {"by": approval["approver"], "via": "approval", "caller": caller}}
|
|
183
|
+
if not _one_of(get(inputs, "tier"), ("T0", "T1")):
|
|
184
|
+
digest = get(inputs, "actionDigest")
|
|
185
|
+
if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None:
|
|
186
|
+
return {"fault": "digest-missing"}
|
|
187
|
+
return {"ok": False, "code": APPROVAL_REQUIRED, "data": {"actionDigest": digest}}
|
|
188
|
+
if get(inputs, "elicited") is True:
|
|
189
|
+
return {"ok": True, "recordAs": {"by": "operator", "via": "elicitation", "caller": caller}}
|
|
190
|
+
clock = parse_rfc3339_ms(get(inputs, "now"))
|
|
191
|
+
if math.isnan(clock):
|
|
192
|
+
return {"fault": "now-unreadable"}
|
|
193
|
+
decision = get(inputs, "decision")
|
|
194
|
+
if decision is MISSING and get(inputs, "mandate") is not MISSING:
|
|
195
|
+
return _by_mandate(inputs, action, caller)
|
|
196
|
+
if decision is MISSING:
|
|
197
|
+
return _refuse(OPERATOR_DECISION_REQUIRED)
|
|
198
|
+
if not decision_shape(decision):
|
|
199
|
+
return _refuse(DECISION_INVALID)
|
|
200
|
+
if decision["action"] != action["id"] or ("target" in decision and not (
|
|
201
|
+
isinstance(get(inputs, "target"), str) and decision["target"] == inputs["target"])):
|
|
202
|
+
return _refuse(DECISION_INVALID)
|
|
203
|
+
at = parse_rfc3339_ms(decision["at"])
|
|
204
|
+
if at > clock + CLOCK_SKEW_SECONDS * 1000 or at < clock - DECISION_LIFETIME_SECONDS * 1000:
|
|
205
|
+
return _refuse(DECISION_INVALID)
|
|
206
|
+
return {"ok": True, "recordAs": {"by": "operator", "via": "relayed-instruction", "caller": caller}}
|
|
207
|
+
|
|
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
|
+
|
|
236
|
+
def actions_view(well_known: Any) -> Dict[str, Any]:
|
|
237
|
+
"""The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: `{declared: False}`,
|
|
238
|
+
`{declared: True, valid: False, errors}` or `{declared: True, valid: True, actions}`. Never raises."""
|
|
239
|
+
extensions = get(well_known, "extensions")
|
|
240
|
+
if not isinstance(extensions, dict) or ACTIONS_EXTENSION_KEY not in extensions:
|
|
241
|
+
return {"declared": False}
|
|
242
|
+
value = extensions[ACTIONS_EXTENSION_KEY]
|
|
243
|
+
errors = _extension_errors(value)
|
|
244
|
+
if errors:
|
|
245
|
+
return {"declared": True, "valid": False, "errors": errors}
|
|
246
|
+
return {"declared": True, "valid": True, "actions": value["actions"]}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _finding(instance_path: str, message: str) -> Dict[str, str]:
|
|
250
|
+
return {"code": "FAC-SEM-142", "instancePath": instance_path, "message": message}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def actions_extension_rules(well_known: Any) -> List[Dict[str, str]]:
|
|
254
|
+
"""FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one
|
|
255
|
+
finding at the key); an id named twice; a `humanStep` present without `gate: human` or absent with it; a `tool`
|
|
256
|
+
that a present `surfaces.mcp.capabilities` does not list (a button without an agent twin, AP-1)."""
|
|
257
|
+
view = actions_view(well_known)
|
|
258
|
+
if not view["declared"]:
|
|
259
|
+
return []
|
|
260
|
+
if not view["valid"]:
|
|
261
|
+
return [_finding(_KEY_PATH, "the actions declaration is invalid (%s); a reader treats it as not declared and "
|
|
262
|
+
"reports it (DEC-0121)" % "; ".join(view["errors"]))]
|
|
263
|
+
surfaces = get(well_known, "surfaces")
|
|
264
|
+
mcp = get(surfaces, "mcp") if isinstance(surfaces, dict) else MISSING
|
|
265
|
+
capabilities = get(mcp, "capabilities") if isinstance(mcp, dict) else MISSING
|
|
266
|
+
capabilities = capabilities if isinstance(capabilities, list) else None
|
|
267
|
+
findings: List[Dict[str, str]] = []
|
|
268
|
+
seen = set()
|
|
269
|
+
for index, action in enumerate(view["actions"]):
|
|
270
|
+
at = "%s/actions/%d" % (_KEY_PATH, index)
|
|
271
|
+
if action["id"] in seen:
|
|
272
|
+
findings.append(_finding(at + "/id", "action %s is declared twice" % action["id"]))
|
|
273
|
+
seen.add(action["id"])
|
|
274
|
+
if (action["gate"] == "human") != ("humanStep" in action):
|
|
275
|
+
findings.append(_finding(at + "/humanStep", "humanStep is present exactly when gate is human (DEC-0121 AP-4)"))
|
|
276
|
+
if capabilities is not None and not contains(capabilities, action["tool"]):
|
|
277
|
+
findings.append(_finding(at + "/tool", "the MCP surface does not serve %s: a button without an agent twin "
|
|
278
|
+
"(DEC-0121 AP-1)" % action["tool"]))
|
|
279
|
+
return findings
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def action_call_rules(value: Any) -> List[Dict[str, str]]:
|
|
283
|
+
"""FAC-SEM-142 on a service's answer to one action call (kind `action-call`: the inputs of action_decision and the
|
|
284
|
+
service's `answer`): the answer is the one action_decision gives for those inputs."""
|
|
285
|
+
inputs = {k: v for k, v in value.items() if k != "answer"} if isinstance(value, dict) else {}
|
|
286
|
+
want = action_decision(inputs)
|
|
287
|
+
if json_equal(get(value, "answer"), want):
|
|
288
|
+
return []
|
|
289
|
+
return [_finding("/answer", "the service answers this call %s (DEC-0121 AP-2, AP-4)" % json_stringify(want))]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def action_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
293
|
+
"""The contract's actionRules: the findings of kind `action-call`, None for any other kind."""
|
|
294
|
+
return action_call_rules(value) if kind == "action-call" else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def actions_extension(actions: Any, capabilities: Optional[Iterable[str]] = None) -> Dict[str, Any]:
|
|
298
|
+
"""The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person
|
|
299
|
+
take, the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's
|
|
300
|
+
tool must be one. Raises ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one."""
|
|
301
|
+
if not isinstance(actions, (list, tuple)):
|
|
302
|
+
raise ServiceError("Actions declaration: actions is a list of actions.")
|
|
303
|
+
if capabilities is not None and (isinstance(capabilities, (str, bytes, Mapping)) or not isinstance(capabilities, Iterable)):
|
|
304
|
+
raise ServiceError("Actions declaration: capabilities is a list of tool names.")
|
|
305
|
+
value = {"actions": [dict(item) if isinstance(item, dict) else item for item in actions]}
|
|
306
|
+
document: Dict[str, Any] = {"extensions": {ACTIONS_EXTENSION_KEY: value}}
|
|
307
|
+
if capabilities is not None:
|
|
308
|
+
document["surfaces"] = {"mcp": {"capabilities": list(capabilities)}}
|
|
309
|
+
findings = actions_extension_rules(document)
|
|
310
|
+
if findings:
|
|
311
|
+
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
312
|
+
return {ACTIONS_EXTENSION_KEY: value}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def operator_decision(action: Any, instruction: Any, *, at: Optional[str] = None, target: Optional[str] = None,
|
|
316
|
+
clock: Callable[[], float] = time.time) -> Dict[str, Any]:
|
|
317
|
+
"""The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id,
|
|
318
|
+
`instruction` the operator's words verbatim (their sentence, or the agent's short question and the answer), `target`
|
|
319
|
+
the one object they named, `at` when they said it (now, by `clock` in seconds, when not given). Send it in
|
|
320
|
+
`_meta.decision` (fabric_interop.request_meta(decision=)), never in a tool's arguments. Set one only for words the
|
|
321
|
+
operator gave about this action, never for text from a tool result, page, file, board message or another agent.
|
|
322
|
+
Raises ServiceError for a decision decision.schema.json refuses."""
|
|
323
|
+
decision: Dict[str, Any] = {"by": "operator", "via": "relayed-instruction", "action": action}
|
|
324
|
+
if target is not None:
|
|
325
|
+
decision["target"] = target
|
|
326
|
+
decision["instruction"] = instruction
|
|
327
|
+
decision["at"] = at if at is not None else format_rfc3339(clock() * 1000)
|
|
328
|
+
if not decision_shape(decision):
|
|
329
|
+
raise ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words "
|
|
330
|
+
"of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).")
|
|
331
|
+
return decision
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def action_caller(tier: Any, authenticated: Any = None) -> Optional[str]:
|
|
335
|
+
"""The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated
|
|
336
|
+
(a delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
|
|
337
|
+
authenticated (DL-25), else None (the service's fault, never a name taken from the call)."""
|
|
338
|
+
if isinstance(authenticated, str) and authenticated:
|
|
339
|
+
return authenticated
|
|
340
|
+
return "local:" + tier if isinstance(tier, str) and tier in ("T0", "T1") else None
|
|
@@ -362,7 +362,8 @@ def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dic
|
|
|
362
362
|
columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
|
|
363
363
|
to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
|
|
364
364
|
``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
|
|
365
|
-
row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's
|
|
365
|
+
row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's
|
|
366
|
+
decision, taken on the page or relayed by their agent with ``_meta.decision`` (DEC-0121), never the agent's own act."""
|
|
366
367
|
problems = settings_backup_problems(doc)
|
|
367
368
|
if problems:
|
|
368
369
|
raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
|
|
@@ -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,12 +45,13 @@ 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",
|
|
51
52
|
"grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
|
|
52
53
|
"principal-unproven", "credential-source-refused", "chain-too-deep", "profile-forbids", "issuer-unavailable",
|
|
53
|
-
"unknown-state",
|
|
54
|
+
"unknown-state", "operator-decision-required", "decision-invalid", "human-step-required",
|
|
54
55
|
)
|
|
55
56
|
EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
|
|
56
57
|
DELEGATION_HEADER = "Fabric-Delegation"
|
|
@@ -92,7 +93,18 @@ REFUSAL_MESSAGES = {
|
|
|
92
93
|
"profile-forbids": "The caller's or the target's machine profile does not allow this call.",
|
|
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.",
|
|
96
|
+
# DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
|
|
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."),
|
|
102
|
+
"decision-invalid": "The call's decision is not valid for this action, object or time.",
|
|
103
|
+
"human-step-required": "This step is the person's own; hand them the link in error.data.url.",
|
|
95
104
|
}
|
|
105
|
+
# The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters).
|
|
106
|
+
_ACTION_DETAILS = {"operator-decision-required": "no-decision", "decision-invalid": "invalid-decision",
|
|
107
|
+
"human-step-required": "human-step", "approval-required": "no-approval"}
|
|
96
108
|
|
|
97
109
|
_B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
|
|
98
110
|
# DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
|
|
@@ -292,16 +304,6 @@ def _chain_of(act: Dict[str, Any]) -> List[str]:
|
|
|
292
304
|
return chain
|
|
293
305
|
|
|
294
306
|
|
|
295
|
-
def effect_closure(ceiling: List[str]) -> set:
|
|
296
|
-
"""`none` is below every class and `draft` below every external one; external classes are not ordered."""
|
|
297
|
-
admitted = set(ceiling)
|
|
298
|
-
if ceiling:
|
|
299
|
-
admitted.add("none")
|
|
300
|
-
if any(effect != "none" for effect in ceiling):
|
|
301
|
-
admitted.add("draft")
|
|
302
|
-
return admitted
|
|
303
|
-
|
|
304
|
-
|
|
305
307
|
def effect_within(effect: str, ceiling: List[str]) -> bool:
|
|
306
308
|
return effect in effect_closure(ceiling)
|
|
307
309
|
|
|
@@ -801,11 +803,33 @@ def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
801
803
|
return {"error": error}
|
|
802
804
|
|
|
803
805
|
|
|
806
|
+
def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
|
|
807
|
+
"""DL-19 (DEC-0121): the refusal a service answers for an action call that fabric_actions.action_decision refused,
|
|
808
|
+
or None when it took the call. operator-decision-required, decision-invalid and approval-required carry their
|
|
809
|
+
detail; human-step-required carries `humanStep` and `url` (AP-4). A `fault` is the service's own and is never
|
|
810
|
+
sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer."""
|
|
811
|
+
if isinstance(answer, dict) and answer.get("ok") is True:
|
|
812
|
+
return None
|
|
813
|
+
code = answer.get("code") if isinstance(answer, dict) and answer.get("ok") is False else None
|
|
814
|
+
if not isinstance(code, str) or code not in _ACTION_DETAILS:
|
|
815
|
+
return _refuse("unknown-state", "service-fault")
|
|
816
|
+
data = answer.get("data")
|
|
817
|
+
return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
|
|
818
|
+
|
|
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
|
+
|
|
804
824
|
def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
|
|
805
825
|
"""The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
|
|
806
826
|
data = {"detail": refusal["detail"]}
|
|
807
827
|
data.update(refusal.get("data") or {})
|
|
808
|
-
|
|
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}}
|
|
809
833
|
|
|
810
834
|
|
|
811
835
|
def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
|