@passioncode-ai/passioncode 0.2.38 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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 +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +47 -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/references/dashboard-kit.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +99 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +45 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +325 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +246 -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-delegation.mjs +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +45 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +302 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +22 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +154 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +45 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +124 -16
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +53 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +41 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -20,6 +20,7 @@ export const REFUSAL_CODES = Object.freeze([
|
|
|
20
20
|
'unauthenticated', 'token-expired', 'wrong-audience', 'scope-not-granted', 'effect-above-ceiling', 'no-grant',
|
|
21
21
|
'grant-expired', 'grant-revoked', 'limit-reached', 'approval-required', 'approval-invalid', 'approval-reused',
|
|
22
22
|
'principal-unproven', 'credential-source-refused', 'chain-too-deep', 'profile-forbids', 'issuer-unavailable', 'unknown-state',
|
|
23
|
+
'operator-decision-required', 'decision-invalid', 'human-step-required',
|
|
23
24
|
]);
|
|
24
25
|
export const EFFECT_CLASSES = Object.freeze(['none', 'draft', 'publish', 'send', 'reply', 'charge', 'delete', 'merge', 'deploy', 'change-policy']);
|
|
25
26
|
export const DELEGATION_HEADER = 'Fabric-Delegation';
|
|
@@ -55,7 +56,13 @@ export const REFUSAL_MESSAGES = Object.freeze({
|
|
|
55
56
|
'profile-forbids': "The caller's or the target's machine profile does not allow this call.",
|
|
56
57
|
'issuer-unavailable': "The estate's published keys cannot be read.",
|
|
57
58
|
'unknown-state': 'The state cannot be established, so the call is refused.',
|
|
59
|
+
// DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
|
|
60
|
+
'operator-decision-required': "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
|
|
61
|
+
'decision-invalid': "The call's decision is not valid for this action, object or time.",
|
|
62
|
+
'human-step-required': "This step is the person's own; hand them the link in error.data.url.",
|
|
58
63
|
});
|
|
64
|
+
/** The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters). */
|
|
65
|
+
const ACTION_DETAILS = new Map([['operator-decision-required', 'no-decision'], ['decision-invalid', 'invalid-decision'], ['human-step-required', 'human-step'], ['approval-required', 'no-approval']]);
|
|
59
66
|
|
|
60
67
|
const refuse = (code, detail, data) => (data ? { ok: false, code, detail, data } : { ok: false, code, detail });
|
|
61
68
|
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
@@ -488,6 +495,21 @@ export function refusalFromDenial(denial) {
|
|
|
488
495
|
return { error: { code: denial.code, message, ...(Object.keys(data).length > 0 ? { data } : {}) } };
|
|
489
496
|
}
|
|
490
497
|
|
|
498
|
+
/**
|
|
499
|
+
* DL-19 (DEC-0121): the refusal a service answers for an action call that fabric-actions.mjs's actionDecision refused,
|
|
500
|
+
* or undefined when it took the call. operator-decision-required, decision-invalid and approval-required carry their
|
|
501
|
+
* detail; human-step-required carries humanStep and url (AP-4). A `fault` is the service's own and is never sent: the
|
|
502
|
+
* caller reads unknown-state with the detail service-fault, as for anything else that is not an answer.
|
|
503
|
+
*/
|
|
504
|
+
export function refusalFromAction(answer) {
|
|
505
|
+
const isRecord = answer !== null && typeof answer === 'object' && !Array.isArray(answer);
|
|
506
|
+
if (isRecord && answer.ok === true) return undefined;
|
|
507
|
+
const code = isRecord && answer.ok === false && typeof answer.code === 'string' ? answer.code : undefined;
|
|
508
|
+
if (code === undefined || !ACTION_DETAILS.has(code)) return refuse('unknown-state', 'service-fault');
|
|
509
|
+
const data = answer.data !== null && typeof answer.data === 'object' && !Array.isArray(answer.data) && Object.keys(answer.data).length ? { ...answer.data } : undefined;
|
|
510
|
+
return refuse(code, ACTION_DETAILS.get(code), data);
|
|
511
|
+
}
|
|
512
|
+
|
|
491
513
|
/** The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key. */
|
|
492
514
|
export const refusalError = (refusal) => ({ error: { code: refusal.code, message: REFUSAL_MESSAGES[refusal.code] ?? 'Refused.', data: { detail: refusal.detail, ...(refusal.data ?? {}) } } });
|
|
493
515
|
|
|
@@ -7,13 +7,14 @@ import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand }
|
|
|
7
7
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
8
8
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
9
9
|
import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
|
|
10
|
+
import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
|
|
10
11
|
import { ROUTES } from './fabric-usage.mjs';
|
|
11
12
|
|
|
12
13
|
// The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
13
14
|
// document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
14
15
|
export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
15
16
|
|
|
16
|
-
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.
|
|
17
|
+
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.5';
|
|
17
18
|
|
|
18
19
|
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
19
20
|
|
|
@@ -377,7 +378,11 @@ export function configurationProblems(doc) {
|
|
|
377
378
|
return problems;
|
|
378
379
|
}
|
|
379
380
|
|
|
380
|
-
|
|
381
|
+
// `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the tool
|
|
382
|
+
// that performs it and its gate, written under extensions/actions/0.1 by actionsExtension. A declaration FAC-SEM-142
|
|
383
|
+
// finds, passed either way, is refused: not its schema's, an id named twice, a humanStep that disagrees with the gate,
|
|
384
|
+
// or a tool surfaces.mcp.capabilities does not list.
|
|
385
|
+
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions }) {
|
|
381
386
|
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
382
387
|
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
383
388
|
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
@@ -391,6 +396,11 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
391
396
|
update: { available: updateAvailable },
|
|
392
397
|
};
|
|
393
398
|
if (summary?.length) doc.summary = summary.slice(0, 6);
|
|
399
|
+
if (actions !== undefined && actions !== null) {
|
|
400
|
+
if (extensions && Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) throw new ServiceError('Actions declaration: pass the action table or a ready declaration, not both.');
|
|
401
|
+
const capabilities = Array.isArray(surfaces.mcp?.capabilities) ? surfaces.mcp.capabilities : undefined;
|
|
402
|
+
extensions = { ...(extensions ?? {}), ...actionsExtension(actions, { capabilities }) };
|
|
403
|
+
}
|
|
394
404
|
if (extensions && Object.keys(extensions).length) {
|
|
395
405
|
if (DASHBOARD_EXTENSION_KEY in extensions) {
|
|
396
406
|
const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
|
|
@@ -401,6 +411,12 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
401
411
|
const view = connectionsView({ extensions: { [CONNECTIONS_EXTENSION_KEY]: extensions[CONNECTIONS_EXTENSION_KEY] } });
|
|
402
412
|
if (view.reported) throw new ServiceError(`Connections declaration: ${view.errors.join('; ')}.`);
|
|
403
413
|
}
|
|
414
|
+
if (Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) {
|
|
415
|
+
// DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The tools are
|
|
416
|
+
// compared with the capabilities this document's MCP surface lists.
|
|
417
|
+
const findings = actionsExtensionRules({ extensions: { [ACTIONS_EXTENSION_KEY]: extensions[ACTIONS_EXTENSION_KEY] }, surfaces });
|
|
418
|
+
if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
|
|
419
|
+
}
|
|
404
420
|
doc.extensions = { ...extensions };
|
|
405
421
|
}
|
|
406
422
|
if (configuration !== undefined) {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// fabric_interop_rules.py. Node 20+, no dependency.
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { ServiceError } from './fabric-base.mjs';
|
|
8
|
-
import { jcs } from './fabric-delegation.mjs';
|
|
8
|
+
import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
|
|
9
|
+
import { actionDecision, decisionShape } from './fabric-actions.mjs'; // DEC-0121
|
|
9
10
|
import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
10
11
|
import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
11
12
|
|
|
@@ -188,13 +189,54 @@ export function readsNullUsage(requestMeta) {
|
|
|
188
189
|
return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
/**
|
|
192
|
-
|
|
192
|
+
/**
|
|
193
|
+
* A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087) and, for a relayed
|
|
194
|
+
* action, the operator's decision (DEC-0121 AP-2; build one with operatorDecision). A decision travels here only, never
|
|
195
|
+
* in a tool's arguments; one decision.schema.json refuses throws InteropError.
|
|
196
|
+
*/
|
|
197
|
+
export function requestMeta(traceparent, { decision } = {}) {
|
|
193
198
|
const meta = { protocols: [...PROTOCOLS_READ] };
|
|
194
199
|
if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
|
|
200
|
+
if (decision !== undefined && decision !== null) {
|
|
201
|
+
if (!decisionShape(decision)) throw new InteropError('The decision is not one decision.schema.json reads; it is not sent.');
|
|
202
|
+
meta.decision = { ...decision };
|
|
203
|
+
}
|
|
195
204
|
return meta;
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
/**
|
|
208
|
+
* One call of a declared action, before its handler runs (DEC-0121 AP-2, AP-4), for a Node service's own MCP server:
|
|
209
|
+
* actionDecision over the call's `_meta.decision` (an own member of `meta` only, never arguments, decision D11), the
|
|
210
|
+
* service's authenticated `caller` (actionCaller), `target`, `elicited` (the service's own record of an accepted
|
|
211
|
+
* elicitation), `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now` (RFC 3339).
|
|
212
|
+
* Answers {ok: true, record, decision}, `decision` being the one taken as the operator's (null otherwise), or {ok: false,
|
|
213
|
+
* refusal} in the DL-19 form (refusalToolResult sends it); a service's own fault is the refusal unknown-state with
|
|
214
|
+
* `fault` beside it for the service's log.
|
|
215
|
+
*/
|
|
216
|
+
export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now } = {}) {
|
|
217
|
+
const inputs = { action, tier, caller, elicited: elicited === true, now };
|
|
218
|
+
if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
|
|
219
|
+
for (const [name, value] of [['target', target], ['approval', approval], ['actionDigest', actionDigest], ['humanStepUrl', humanStepUrl]]) {
|
|
220
|
+
if (value !== undefined && value !== null) inputs[name] = value;
|
|
221
|
+
}
|
|
222
|
+
const answer = actionDecision(inputs);
|
|
223
|
+
if (answer.ok === true) {
|
|
224
|
+
const record = answer.recordAs;
|
|
225
|
+
const taken = isObject(record) && record.via === 'relayed-instruction';
|
|
226
|
+
return { ok: true, record, decision: taken ? inputs.decision : null };
|
|
227
|
+
}
|
|
228
|
+
const out = { ok: false, refusal: refusalFromAction(answer) };
|
|
229
|
+
if ('fault' in answer) out.fault = answer.fault;
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The tools/call result a service's own MCP server answers for an actionDecision answer: null when the action takes
|
|
234
|
+
* the call, else DL-19's refusal as structuredContent with isError true (DEC-0121). */
|
|
235
|
+
export function actionRefusalToolResult(answer, traceparent) {
|
|
236
|
+
const refusal = refusalFromAction(answer);
|
|
237
|
+
return refusal === undefined ? null : refusalToolResult(refusal, traceparent);
|
|
238
|
+
}
|
|
239
|
+
|
|
198
240
|
/** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
|
|
199
241
|
export function usageFindings(usage, at) {
|
|
200
242
|
if (!isObject(usage) || usage.partial !== false) return [];
|
|
@@ -8,7 +8,7 @@ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric
|
|
|
8
8
|
/** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
|
|
9
9
|
export const PROTOCOL_REVISIONS = new Map(Object.entries({
|
|
10
10
|
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'agent-directory': ['1'], 'call-record': ['1'], 'coding-agents': ['1'], 'credential-decision': ['1'], 'credential-request': ['1'], 'credential-revocation': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
11
|
-
'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
11
|
+
'fabric-actions': ['0.1'], 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
12
12
|
'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
13
13
|
'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
|
|
14
14
|
'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
|
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
feedDataFindings,
|
|
41
41
|
feedDataProblems,
|
|
42
42
|
} from './fabric-protocols.mjs';
|
|
43
|
+
export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
|
|
43
44
|
export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
|
|
44
45
|
export {
|
|
45
46
|
EXIT_ALREADY_RUNNING,
|
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
|
|
18
|
+
The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
|
|
19
|
+
patterns over the whole string, as the contract's validator reads them. Never raises on what it reads. Standard library
|
|
20
|
+
only.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
|
|
28
|
+
|
|
29
|
+
import sys as _sys # noqa: E402
|
|
30
|
+
from pathlib import Path as _Path # noqa: E402
|
|
31
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
32
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
33
|
+
|
|
34
|
+
from fabric_base import ServiceError # noqa: E402
|
|
35
|
+
from fabric_jsvalue import MISSING, contains, get, json_equal, json_stringify # noqa: E402
|
|
36
|
+
from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
|
|
37
|
+
|
|
38
|
+
ACTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1"
|
|
39
|
+
#: The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets.
|
|
40
|
+
OPERATOR_DECISION_REQUIRED = "operator-decision-required"
|
|
41
|
+
DECISION_INVALID = "decision-invalid"
|
|
42
|
+
HUMAN_STEP_REQUIRED = "human-step-required"
|
|
43
|
+
APPROVAL_REQUIRED = "approval-required"
|
|
44
|
+
GATES = ("none", "relayed", "human")
|
|
45
|
+
HUMAN_STEPS = ("sign-in", "consent", "payment", "secret", "spend-above-limit", "passkey")
|
|
46
|
+
MAX_ACTIONS = 128
|
|
47
|
+
#: How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS).
|
|
48
|
+
CLOCK_SKEW_SECONDS = 60
|
|
49
|
+
#: How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051).
|
|
50
|
+
DECISION_LIFETIME_SECONDS = 24 * 60 * 60
|
|
51
|
+
|
|
52
|
+
_REQUEST_KEY = re.compile(r"[A-Za-z0-9_.-]{1,64}", re.ASCII) # interop-common.schema.json#/$defs/requestKey
|
|
53
|
+
_CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}", re.ASCII) # common.schema.json#/$defs/capabilityName
|
|
54
|
+
_SHA256 = re.compile(r"sha256:[0-9a-f]{64}", re.ASCII)
|
|
55
|
+
_STEP_URL = re.compile(r"https://|/") # a prefix, as the contract's /^(https:\/\/|\/)/ tests it
|
|
56
|
+
_ACTION_MEMBERS = ("id", "title", "tool", "gate", "humanStep")
|
|
57
|
+
_DECISION_MEMBERS = ("by", "via", "action", "target", "instruction", "at")
|
|
58
|
+
_KEY_PATH = "/extensions/" + ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _text(value: Any, low: int, high: int) -> bool:
|
|
62
|
+
"""A string of low..high code points, as the validator counts them (Python counts code points)."""
|
|
63
|
+
return isinstance(value, str) and low <= len(value) <= high
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _matches(pattern: "re.Pattern[str]", value: Any, high: int) -> bool:
|
|
67
|
+
return isinstance(value, str) and len(value) <= high and pattern.fullmatch(value) is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _non_empty(value: Any) -> bool:
|
|
71
|
+
return isinstance(value, str) and len(value) > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _one_of(value: Any, choices: Iterable[str]) -> bool:
|
|
75
|
+
return isinstance(value, str) and value in choices
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _action_errors(value: Any, at: str) -> List[str]:
|
|
79
|
+
"""`service-actions-extension.schema.json#/$defs/action`: the errors at `at`, empty for a valid entry."""
|
|
80
|
+
if not isinstance(value, dict):
|
|
81
|
+
return ["%s type" % at]
|
|
82
|
+
errors = ["%s/%s additionalProperties" % (at, key) for key in value if key not in _ACTION_MEMBERS]
|
|
83
|
+
errors += ["%s required %s" % (at, key) for key in ("id", "title", "tool", "gate") if key not in value]
|
|
84
|
+
if "id" in value and not _matches(_REQUEST_KEY, value["id"], 64):
|
|
85
|
+
errors.append("%s/id pattern" % at)
|
|
86
|
+
if "title" in value and not _text(value["title"], 1, 80):
|
|
87
|
+
errors.append("%s/title maxLength" % at)
|
|
88
|
+
if "tool" in value and not _matches(_CAPABILITY, value["tool"], 128):
|
|
89
|
+
errors.append("%s/tool pattern" % at)
|
|
90
|
+
if "gate" in value and not _one_of(value["gate"], GATES):
|
|
91
|
+
errors.append("%s/gate enum" % at)
|
|
92
|
+
if "humanStep" in value and not _one_of(value["humanStep"], HUMAN_STEPS):
|
|
93
|
+
errors.append("%s/humanStep enum" % at)
|
|
94
|
+
return errors
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extension_errors(value: Any) -> List[str]:
|
|
98
|
+
"""`service-actions-extension.schema.json`: the errors, empty for a valid declaration."""
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
return ["/ type"]
|
|
101
|
+
errors = ["/%s additionalProperties" % key for key in value if key != "actions"]
|
|
102
|
+
if "actions" not in value:
|
|
103
|
+
return errors + ["/ required actions"]
|
|
104
|
+
actions = value["actions"]
|
|
105
|
+
if not isinstance(actions, list):
|
|
106
|
+
return errors + ["/actions type"]
|
|
107
|
+
if not 1 <= len(actions) <= MAX_ACTIONS:
|
|
108
|
+
errors.append("/actions %s" % ("minItems" if not actions else "maxItems"))
|
|
109
|
+
for index, action in enumerate(actions):
|
|
110
|
+
errors += _action_errors(action, "/actions/%d" % index)
|
|
111
|
+
return errors
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def readable_action(action: Any) -> bool:
|
|
115
|
+
"""One valid `actions[]` entry whose `humanStep` is present exactly when `gate` is `human`."""
|
|
116
|
+
return not _action_errors(action, "") and (action["gate"] == "human") == ("humanStep" in action)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def decision_shape(value: Any) -> bool:
|
|
120
|
+
"""`decision.schema.json` (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional
|
|
121
|
+
target of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats'
|
|
122
|
+
date-time also admits a space for T and a leap second, which this refuses, decision D5)."""
|
|
123
|
+
if not isinstance(value, dict) or any(key not in _DECISION_MEMBERS for key in value):
|
|
124
|
+
return False
|
|
125
|
+
if any(key not in value for key in ("by", "via", "action", "instruction", "at")):
|
|
126
|
+
return False
|
|
127
|
+
return (isinstance(value["by"], str) and value["by"] == "operator"
|
|
128
|
+
and isinstance(value["via"], str) and value["via"] == "relayed-instruction"
|
|
129
|
+
and _matches(_REQUEST_KEY, value["action"], 64)
|
|
130
|
+
and ("target" not in value or _text(value["target"], 1, 200))
|
|
131
|
+
and _text(value["instruction"], 1, 500)
|
|
132
|
+
and isinstance(value["at"], str) and not math.isnan(parse_rfc3339_ms(value["at"])))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _accepted_approval(approval: Any) -> bool:
|
|
136
|
+
return isinstance(approval, dict) and approval.get("ok") is True and _non_empty(approval.get("approver"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _refuse(code: str) -> Dict[str, Any]:
|
|
140
|
+
return {"ok": False, "code": code}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def action_decision(inputs: Any) -> Dict[str, Any]:
|
|
144
|
+
"""Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4;
|
|
145
|
+
DL-17, DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
|
|
146
|
+
declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
|
|
147
|
+
`decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted
|
|
148
|
+
elicitation), `approval` (verify_approval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
|
|
149
|
+
|
|
150
|
+
- `none`: the caller's own act. `human`: `human-step-required` with the step and its link, at every tier.
|
|
151
|
+
- `relayed`: an accepted approval is the approver's; a T2 caller without one gets `approval-required` with the
|
|
152
|
+
digest; an accepted elicitation is the operator's; no decision is `operator-decision-required`; a decision that
|
|
153
|
+
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
|
+
than 24 h behind it is `decision-invalid`; otherwise the operator's act, recorded with `caller`.
|
|
155
|
+
- `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing) is the service's
|
|
156
|
+
own: an input it owes is missing or unreadable. It is never sent to a caller.
|
|
157
|
+
"""
|
|
158
|
+
action = get(inputs, "action")
|
|
159
|
+
if not readable_action(action):
|
|
160
|
+
return {"fault": "action-invalid"}
|
|
161
|
+
if action["gate"] == "none":
|
|
162
|
+
return {"ok": True, "recordAs": "caller"}
|
|
163
|
+
if action["gate"] == "human":
|
|
164
|
+
url = get(inputs, "humanStepUrl")
|
|
165
|
+
if not _non_empty(url) or not _STEP_URL.match(url):
|
|
166
|
+
return {"fault": "url-missing"}
|
|
167
|
+
return {"ok": False, "code": HUMAN_STEP_REQUIRED, "data": {"humanStep": action["humanStep"], "url": url}}
|
|
168
|
+
caller = get(inputs, "caller")
|
|
169
|
+
if not _non_empty(caller):
|
|
170
|
+
return {"fault": "caller-missing"}
|
|
171
|
+
approval = get(inputs, "approval")
|
|
172
|
+
if _accepted_approval(approval):
|
|
173
|
+
return {"ok": True, "recordAs": {"by": approval["approver"], "via": "approval", "caller": caller}}
|
|
174
|
+
if not _one_of(get(inputs, "tier"), ("T0", "T1")):
|
|
175
|
+
digest = get(inputs, "actionDigest")
|
|
176
|
+
if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None:
|
|
177
|
+
return {"fault": "digest-missing"}
|
|
178
|
+
return {"ok": False, "code": APPROVAL_REQUIRED, "data": {"actionDigest": digest}}
|
|
179
|
+
if get(inputs, "elicited") is True:
|
|
180
|
+
return {"ok": True, "recordAs": {"by": "operator", "via": "elicitation", "caller": caller}}
|
|
181
|
+
clock = parse_rfc3339_ms(get(inputs, "now"))
|
|
182
|
+
if math.isnan(clock):
|
|
183
|
+
return {"fault": "now-unreadable"}
|
|
184
|
+
decision = get(inputs, "decision")
|
|
185
|
+
if decision is MISSING:
|
|
186
|
+
return _refuse(OPERATOR_DECISION_REQUIRED)
|
|
187
|
+
if not decision_shape(decision):
|
|
188
|
+
return _refuse(DECISION_INVALID)
|
|
189
|
+
if decision["action"] != action["id"] or ("target" in decision and not (
|
|
190
|
+
isinstance(get(inputs, "target"), str) and decision["target"] == inputs["target"])):
|
|
191
|
+
return _refuse(DECISION_INVALID)
|
|
192
|
+
at = parse_rfc3339_ms(decision["at"])
|
|
193
|
+
if at > clock + CLOCK_SKEW_SECONDS * 1000 or at < clock - DECISION_LIFETIME_SECONDS * 1000:
|
|
194
|
+
return _refuse(DECISION_INVALID)
|
|
195
|
+
return {"ok": True, "recordAs": {"by": "operator", "via": "relayed-instruction", "caller": caller}}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def actions_view(well_known: Any) -> Dict[str, Any]:
|
|
199
|
+
"""The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: `{declared: False}`,
|
|
200
|
+
`{declared: True, valid: False, errors}` or `{declared: True, valid: True, actions}`. Never raises."""
|
|
201
|
+
extensions = get(well_known, "extensions")
|
|
202
|
+
if not isinstance(extensions, dict) or ACTIONS_EXTENSION_KEY not in extensions:
|
|
203
|
+
return {"declared": False}
|
|
204
|
+
value = extensions[ACTIONS_EXTENSION_KEY]
|
|
205
|
+
errors = _extension_errors(value)
|
|
206
|
+
if errors:
|
|
207
|
+
return {"declared": True, "valid": False, "errors": errors}
|
|
208
|
+
return {"declared": True, "valid": True, "actions": value["actions"]}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _finding(instance_path: str, message: str) -> Dict[str, str]:
|
|
212
|
+
return {"code": "FAC-SEM-142", "instancePath": instance_path, "message": message}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def actions_extension_rules(well_known: Any) -> List[Dict[str, str]]:
|
|
216
|
+
"""FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one
|
|
217
|
+
finding at the key); an id named twice; a `humanStep` present without `gate: human` or absent with it; a `tool`
|
|
218
|
+
that a present `surfaces.mcp.capabilities` does not list (a button without an agent twin, AP-1)."""
|
|
219
|
+
view = actions_view(well_known)
|
|
220
|
+
if not view["declared"]:
|
|
221
|
+
return []
|
|
222
|
+
if not view["valid"]:
|
|
223
|
+
return [_finding(_KEY_PATH, "the actions declaration is invalid (%s); a reader treats it as not declared and "
|
|
224
|
+
"reports it (DEC-0121)" % "; ".join(view["errors"]))]
|
|
225
|
+
surfaces = get(well_known, "surfaces")
|
|
226
|
+
mcp = get(surfaces, "mcp") if isinstance(surfaces, dict) else MISSING
|
|
227
|
+
capabilities = get(mcp, "capabilities") if isinstance(mcp, dict) else MISSING
|
|
228
|
+
capabilities = capabilities if isinstance(capabilities, list) else None
|
|
229
|
+
findings: List[Dict[str, str]] = []
|
|
230
|
+
seen = set()
|
|
231
|
+
for index, action in enumerate(view["actions"]):
|
|
232
|
+
at = "%s/actions/%d" % (_KEY_PATH, index)
|
|
233
|
+
if action["id"] in seen:
|
|
234
|
+
findings.append(_finding(at + "/id", "action %s is declared twice" % action["id"]))
|
|
235
|
+
seen.add(action["id"])
|
|
236
|
+
if (action["gate"] == "human") != ("humanStep" in action):
|
|
237
|
+
findings.append(_finding(at + "/humanStep", "humanStep is present exactly when gate is human (DEC-0121 AP-4)"))
|
|
238
|
+
if capabilities is not None and not contains(capabilities, action["tool"]):
|
|
239
|
+
findings.append(_finding(at + "/tool", "the MCP surface does not serve %s: a button without an agent twin "
|
|
240
|
+
"(DEC-0121 AP-1)" % action["tool"]))
|
|
241
|
+
return findings
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def action_call_rules(value: Any) -> List[Dict[str, str]]:
|
|
245
|
+
"""FAC-SEM-142 on a service's answer to one action call (kind `action-call`: the inputs of action_decision and the
|
|
246
|
+
service's `answer`): the answer is the one action_decision gives for those inputs."""
|
|
247
|
+
inputs = {k: v for k, v in value.items() if k != "answer"} if isinstance(value, dict) else {}
|
|
248
|
+
want = action_decision(inputs)
|
|
249
|
+
if json_equal(get(value, "answer"), want):
|
|
250
|
+
return []
|
|
251
|
+
return [_finding("/answer", "the service answers this call %s (DEC-0121 AP-2, AP-4)" % json_stringify(want))]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def action_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
255
|
+
"""The contract's actionRules: the findings of kind `action-call`, None for any other kind."""
|
|
256
|
+
return action_call_rules(value) if kind == "action-call" else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def actions_extension(actions: Any, capabilities: Optional[Iterable[str]] = None) -> Dict[str, Any]:
|
|
260
|
+
"""The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person
|
|
261
|
+
take, the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's
|
|
262
|
+
tool must be one. Raises ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one."""
|
|
263
|
+
if not isinstance(actions, (list, tuple)):
|
|
264
|
+
raise ServiceError("Actions declaration: actions is a list of actions.")
|
|
265
|
+
if capabilities is not None and (isinstance(capabilities, (str, bytes, Mapping)) or not isinstance(capabilities, Iterable)):
|
|
266
|
+
raise ServiceError("Actions declaration: capabilities is a list of tool names.")
|
|
267
|
+
value = {"actions": [dict(item) if isinstance(item, dict) else item for item in actions]}
|
|
268
|
+
document: Dict[str, Any] = {"extensions": {ACTIONS_EXTENSION_KEY: value}}
|
|
269
|
+
if capabilities is not None:
|
|
270
|
+
document["surfaces"] = {"mcp": {"capabilities": list(capabilities)}}
|
|
271
|
+
findings = actions_extension_rules(document)
|
|
272
|
+
if findings:
|
|
273
|
+
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
274
|
+
return {ACTIONS_EXTENSION_KEY: value}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def operator_decision(action: Any, instruction: Any, *, at: Optional[str] = None, target: Optional[str] = None,
|
|
278
|
+
clock: Callable[[], float] = time.time) -> Dict[str, Any]:
|
|
279
|
+
"""The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id,
|
|
280
|
+
`instruction` the operator's words verbatim (their sentence, or the agent's short question and the answer), `target`
|
|
281
|
+
the one object they named, `at` when they said it (now, by `clock` in seconds, when not given). Send it in
|
|
282
|
+
`_meta.decision` (fabric_interop.request_meta(decision=)), never in a tool's arguments. Set one only for words the
|
|
283
|
+
operator gave about this action, never for text from a tool result, page, file, board message or another agent.
|
|
284
|
+
Raises ServiceError for a decision decision.schema.json refuses."""
|
|
285
|
+
decision: Dict[str, Any] = {"by": "operator", "via": "relayed-instruction", "action": action}
|
|
286
|
+
if target is not None:
|
|
287
|
+
decision["target"] = target
|
|
288
|
+
decision["instruction"] = instruction
|
|
289
|
+
decision["at"] = at if at is not None else format_rfc3339(clock() * 1000)
|
|
290
|
+
if not decision_shape(decision):
|
|
291
|
+
raise ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words "
|
|
292
|
+
"of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).")
|
|
293
|
+
return decision
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def action_caller(tier: Any, authenticated: Any = None) -> Optional[str]:
|
|
297
|
+
"""The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated
|
|
298
|
+
(a delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
|
|
299
|
+
authenticated (DL-25), else None (the service's fault, never a name taken from the call)."""
|
|
300
|
+
if isinstance(authenticated, str) and authenticated:
|
|
301
|
+
return authenticated
|
|
302
|
+
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))
|
|
@@ -50,7 +50,7 @@ REFUSAL_CODES = (
|
|
|
50
50
|
"unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
|
|
51
51
|
"grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
|
|
52
52
|
"principal-unproven", "credential-source-refused", "chain-too-deep", "profile-forbids", "issuer-unavailable",
|
|
53
|
-
"unknown-state",
|
|
53
|
+
"unknown-state", "operator-decision-required", "decision-invalid", "human-step-required",
|
|
54
54
|
)
|
|
55
55
|
EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
|
|
56
56
|
DELEGATION_HEADER = "Fabric-Delegation"
|
|
@@ -92,7 +92,14 @@ REFUSAL_MESSAGES = {
|
|
|
92
92
|
"profile-forbids": "The caller's or the target's machine profile does not allow this call.",
|
|
93
93
|
"issuer-unavailable": "The estate's published keys cannot be read.",
|
|
94
94
|
"unknown-state": "The state cannot be established, so the call is refused.",
|
|
95
|
+
# 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 _meta.decision.",
|
|
97
|
+
"decision-invalid": "The call's decision is not valid for this action, object or time.",
|
|
98
|
+
"human-step-required": "This step is the person's own; hand them the link in error.data.url.",
|
|
95
99
|
}
|
|
100
|
+
# The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters).
|
|
101
|
+
_ACTION_DETAILS = {"operator-decision-required": "no-decision", "decision-invalid": "invalid-decision",
|
|
102
|
+
"human-step-required": "human-step", "approval-required": "no-approval"}
|
|
96
103
|
|
|
97
104
|
_B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
|
|
98
105
|
# DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
|
|
@@ -801,6 +808,20 @@ def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
801
808
|
return {"error": error}
|
|
802
809
|
|
|
803
810
|
|
|
811
|
+
def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
|
|
812
|
+
"""DL-19 (DEC-0121): the refusal a service answers for an action call that fabric_actions.action_decision refused,
|
|
813
|
+
or None when it took the call. operator-decision-required, decision-invalid and approval-required carry their
|
|
814
|
+
detail; human-step-required carries `humanStep` and `url` (AP-4). A `fault` is the service's own and is never
|
|
815
|
+
sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer."""
|
|
816
|
+
if isinstance(answer, dict) and answer.get("ok") is True:
|
|
817
|
+
return None
|
|
818
|
+
code = answer.get("code") if isinstance(answer, dict) and answer.get("ok") is False else None
|
|
819
|
+
if not isinstance(code, str) or code not in _ACTION_DETAILS:
|
|
820
|
+
return _refuse("unknown-state", "service-fault")
|
|
821
|
+
data = answer.get("data")
|
|
822
|
+
return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
|
|
823
|
+
|
|
824
|
+
|
|
804
825
|
def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
|
|
805
826
|
"""The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
|
|
806
827
|
data = {"detail": refusal["detail"]}
|
|
@@ -36,6 +36,7 @@ from fabric_protocols import (
|
|
|
36
36
|
protocols_findings,
|
|
37
37
|
)
|
|
38
38
|
from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
|
|
39
|
+
from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
|
|
39
40
|
from fabric_usage import (
|
|
40
41
|
ROUTES,
|
|
41
42
|
)
|
|
@@ -46,7 +47,7 @@ from fabric_usage import (
|
|
|
46
47
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
50
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -577,11 +578,16 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
577
578
|
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
578
579
|
extensions: Optional[Dict[str, Any]] = None,
|
|
579
580
|
configuration: Optional[Dict[str, Any]] = None,
|
|
580
|
-
protocols: Optional[Sequence[str]] = None
|
|
581
|
+
protocols: Optional[Sequence[str]] = None,
|
|
582
|
+
actions: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
581
583
|
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
582
584
|
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
583
585
|
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
584
|
-
in it must be valid (dashboard_extension builds one).
|
|
586
|
+
in it must be valid (dashboard_extension builds one).
|
|
587
|
+
`actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
|
|
588
|
+
tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
|
|
589
|
+
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."""
|
|
585
591
|
if status not in STATUSES:
|
|
586
592
|
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
587
593
|
degraded = list(degraded)
|
|
@@ -600,6 +606,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
600
606
|
}
|
|
601
607
|
if summary:
|
|
602
608
|
doc["summary"] = list(summary)[:6]
|
|
609
|
+
if actions is not None:
|
|
610
|
+
if extensions and ACTIONS_EXTENSION_KEY in extensions:
|
|
611
|
+
raise ServiceError("Actions declaration: pass the action table or a ready declaration, not both.")
|
|
612
|
+
mcp = surfaces.get("mcp")
|
|
613
|
+
capabilities = mcp.get("capabilities") if isinstance(mcp, dict) else None
|
|
614
|
+
extensions = dict(extensions or {}, **actions_extension(actions, capabilities if isinstance(capabilities, list) else None))
|
|
603
615
|
if extensions:
|
|
604
616
|
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
605
617
|
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
@@ -610,6 +622,13 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
610
622
|
view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
|
|
611
623
|
if view["reported"]:
|
|
612
624
|
raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
|
|
625
|
+
if ACTIONS_EXTENSION_KEY in extensions:
|
|
626
|
+
# DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The
|
|
627
|
+
# tools are compared with the capabilities this document's MCP surface lists.
|
|
628
|
+
findings = actions_extension_rules({"extensions": {ACTIONS_EXTENSION_KEY: extensions[ACTIONS_EXTENSION_KEY]},
|
|
629
|
+
"surfaces": surfaces})
|
|
630
|
+
if findings:
|
|
631
|
+
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
613
632
|
doc["extensions"] = dict(extensions)
|
|
614
633
|
if configuration is not None:
|
|
615
634
|
problems = configuration_problems(configuration)
|