@passioncode-ai/fabric-agent-adapter 0.77.0 → 0.79.1
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 +207 -0
- package/README.md +31 -11
- package/SECURITY.md +17 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +15 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +27 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +74 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +339 -26
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +367 -27
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +323 -25
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +30 -39
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). The twin of
|
|
2
|
+
// fabric_tailnet.py; both answer test/fixtures/tailnet/vectors.json alike. No dependencies beyond node:crypto and
|
|
3
|
+
// node:net.
|
|
4
|
+
//
|
|
5
|
+
// The header `Tailscale-User-Login` is believed only on the agent's own serve listener (a loopback port or a Unix
|
|
6
|
+
// socket that only serve reaches) and only from a loopback peer; on any other listener a request carrying it is
|
|
7
|
+
// refused. What a person may open comes from a `tailnet-perimeter/1` file generated from the organization's access
|
|
8
|
+
// registry (it names its source in `generatedFrom`); MCP also takes a personal key, stored as its SHA-256, owned by one
|
|
9
|
+
// login and no wider than that login's groups. Refusals are 401 or 403 with a stable `code`.
|
|
10
|
+
|
|
11
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
|
|
14
|
+
export const PERIMETER_SCHEMA = 'tailnet-perimeter/1';
|
|
15
|
+
export const IDENTITY_HEADER = 'tailscale-user-login';
|
|
16
|
+
export const SERVE_HEADERS = Object.freeze(['tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic']);
|
|
17
|
+
export const LISTENER_SERVE = 'serve';
|
|
18
|
+
export const SURFACES = Object.freeze(['dashboard', 'mcp']);
|
|
19
|
+
|
|
20
|
+
const LOGIN = /^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$/;
|
|
21
|
+
const NAME = /^[a-z][a-z0-9._:/-]{0,127}$/;
|
|
22
|
+
const KEY_HASH = /^sha256:[0-9a-f]{64}$/;
|
|
23
|
+
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
24
|
+
|
|
25
|
+
export const REFUSALS = Object.freeze({
|
|
26
|
+
'serve-header-off-serve': 403,
|
|
27
|
+
'peer-not-loopback': 403,
|
|
28
|
+
'identity-missing': 401,
|
|
29
|
+
'identity-ambiguous': 401,
|
|
30
|
+
'identity-malformed': 401,
|
|
31
|
+
'not-in-perimeter': 403,
|
|
32
|
+
'dashboard-not-granted': 403,
|
|
33
|
+
'capability-not-granted': 403,
|
|
34
|
+
'key-required': 401,
|
|
35
|
+
'key-unknown': 401,
|
|
36
|
+
'key-not-yours': 403,
|
|
37
|
+
'key-wider-than-group': 403,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A perimeter or key list that cannot be used: `code` names why, `where` the place, never a value. */
|
|
41
|
+
export class PerimeterError extends Error {
|
|
42
|
+
constructor(code, where = '') {
|
|
43
|
+
super(`${code}${where ? ` at ${where}` : ''}`);
|
|
44
|
+
this.name = 'PerimeterError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.where = where;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The stored form of a personal key: `sha256:<hex>` of its UTF-8 bytes. */
|
|
51
|
+
export function hashKey(value) {
|
|
52
|
+
if (typeof value !== 'string' || !value) throw new TypeError('a key is a non-empty string');
|
|
53
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sortedSet = (values) => [...new Set(values)].sort();
|
|
57
|
+
const isRecordValue = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
const pyKeySort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
59
|
+
|
|
60
|
+
function names(value, where) {
|
|
61
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && NAME.test(v))) {
|
|
62
|
+
throw new PerimeterError('perimeter-invalid', where);
|
|
63
|
+
}
|
|
64
|
+
return sortedSet(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check a parsed `tailnet-perimeter/1` document and answer its normalized form; throw PerimeterError otherwise. */
|
|
68
|
+
export function loadPerimeter(doc) {
|
|
69
|
+
if (!isRecordValue(doc) || doc.schema !== PERIMETER_SCHEMA) throw new PerimeterError('perimeter-schema', 'schema');
|
|
70
|
+
const source = doc.generatedFrom;
|
|
71
|
+
if (!isRecordValue(source) || !['registry', 'revision', 'at'].every((k) => typeof source[k] === 'string' && source[k])) {
|
|
72
|
+
throw new PerimeterError('perimeter-not-generated', 'generatedFrom');
|
|
73
|
+
}
|
|
74
|
+
const groups = doc.groups;
|
|
75
|
+
if (!isRecordValue(groups) || Object.keys(groups).length === 0) throw new PerimeterError('perimeter-invalid', 'groups');
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const name of Object.keys(groups).sort(pyKeySort)) {
|
|
78
|
+
const group = groups[name];
|
|
79
|
+
const where = NAME.test(name) ? `groups.${name}` : 'groups';
|
|
80
|
+
if (!NAME.test(name) || !isRecordValue(group)) throw new PerimeterError('perimeter-invalid', where);
|
|
81
|
+
const members = group.members;
|
|
82
|
+
if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
|
|
83
|
+
throw new PerimeterError('perimeter-invalid', `${where}.members`);
|
|
84
|
+
}
|
|
85
|
+
out[name] = {
|
|
86
|
+
members: sortedSet(members.map((m) => m.toLowerCase())),
|
|
87
|
+
dashboards: names(group.dashboards ?? [], `${where}.dashboards`),
|
|
88
|
+
capabilities: names(group.capabilities ?? [], `${where}.capabilities`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schema: PERIMETER_SCHEMA,
|
|
93
|
+
generatedFrom: { registry: source.registry, revision: source.revision, at: source.at },
|
|
94
|
+
groups: out,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Check a list of personal keys `{id, login, sha256, capabilities}` (the hash only, never the key). */
|
|
99
|
+
export function loadKeys(keys) {
|
|
100
|
+
if (!Array.isArray(keys)) throw new PerimeterError('keys-invalid', 'keys');
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
keys.forEach((key, i) => {
|
|
104
|
+
const where = `keys[${i}]`;
|
|
105
|
+
if (!isRecordValue(key) || typeof key.id !== 'string' || !KEY_ID.test(key.id)
|
|
106
|
+
|| typeof key.login !== 'string' || !LOGIN.test(key.login)
|
|
107
|
+
|| typeof key.sha256 !== 'string' || !KEY_HASH.test(key.sha256)) {
|
|
108
|
+
throw new PerimeterError('keys-invalid', where);
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(key.id) || out.some((k) => k.sha256 === key.sha256)) throw new PerimeterError('keys-duplicate', where);
|
|
111
|
+
seen.add(key.id);
|
|
112
|
+
out.push({ id: key.id, login: key.login.toLowerCase(), sha256: key.sha256,
|
|
113
|
+
capabilities: names(key.capabilities ?? [], `${where}.capabilities`) });
|
|
114
|
+
});
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function headerValues(headers, name) {
|
|
119
|
+
// A list of pairs, a Map or a fetch Headers (anything with entries()), or a plain object such as Node's req.headers.
|
|
120
|
+
const plain = headers === null || headers === undefined || Object.getPrototypeOf(headers) === Object.prototype
|
|
121
|
+
|| Object.getPrototypeOf(headers) === null;
|
|
122
|
+
const pairs = Array.isArray(headers) ? headers
|
|
123
|
+
: !plain && typeof headers.entries === 'function' ? [...headers.entries()]
|
|
124
|
+
: Object.entries(headers ?? {});
|
|
125
|
+
const found = [];
|
|
126
|
+
for (const [key, value] of pairs) {
|
|
127
|
+
if (typeof key === 'string' && key.toLowerCase() === name) found.push(...(Array.isArray(value) ? value : [value]));
|
|
128
|
+
}
|
|
129
|
+
return found.map((v) => (typeof v === 'string' ? v : ''));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isLoopback(address) {
|
|
133
|
+
if (address === 'unix') return true;
|
|
134
|
+
if (typeof address !== 'string') return false;
|
|
135
|
+
const ip = address.split('%', 1)[0];
|
|
136
|
+
const family = isIP(ip);
|
|
137
|
+
if (family === 4) return ip.split('.')[0] === '127';
|
|
138
|
+
if (family !== 6) return false;
|
|
139
|
+
const host = new URL(`http://[${ip}]/`).hostname; // the canonical compressed form: [::1], [::ffff:7f00:1]
|
|
140
|
+
return host === '[::1]' || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const refuse = (code, login = null, groups = []) => ({ allowed: false, status: REFUSALS[code], code, login, groups });
|
|
144
|
+
|
|
145
|
+
function bearer(headers) {
|
|
146
|
+
const values = headerValues(headers, 'authorization');
|
|
147
|
+
if (values.length !== 1) return null;
|
|
148
|
+
const text = values[0].trim();
|
|
149
|
+
const space = text.indexOf(' ');
|
|
150
|
+
if (space < 0) return null;
|
|
151
|
+
const scheme = text.slice(0, space);
|
|
152
|
+
const rest = text.slice(space + 1).trim();
|
|
153
|
+
return scheme.toLowerCase() === 'bearer' && rest && !rest.includes(' ') ? rest : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameDigest(a, b) {
|
|
157
|
+
const x = Buffer.from(a, 'utf8');
|
|
158
|
+
const y = Buffer.from(b, 'utf8');
|
|
159
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide one request. `listener` is which of the agent's listeners took it (`serve` for serve's own); `peer` the
|
|
164
|
+
* connecting address (`unix` for a Unix socket); `perimeter` and `keys` as loadPerimeter and loadKeys answer;
|
|
165
|
+
* `surface` `dashboard` or `mcp`; `target` the dashboard's or the capability's name. Answers
|
|
166
|
+
* {allowed, status, code, login, groups}: status 200 with code `ok`, else 401 or 403 with the refusal's code.
|
|
167
|
+
*/
|
|
168
|
+
export function authorize({ listener, peer, headers, perimeter, surface, target, keys = null }) {
|
|
169
|
+
if (!SURFACES.includes(surface)) throw new TypeError(`surface is one of ${SURFACES.join(', ')}`);
|
|
170
|
+
if (listener !== LISTENER_SERVE) {
|
|
171
|
+
if (SERVE_HEADERS.some((h) => headerValues(headers, h).length)) return refuse('serve-header-off-serve');
|
|
172
|
+
throw new TypeError("authorize decides serve's listener; the Fabric listener keeps its session key");
|
|
173
|
+
}
|
|
174
|
+
if (!isLoopback(peer)) return refuse('peer-not-loopback');
|
|
175
|
+
const logins = headerValues(headers, IDENTITY_HEADER);
|
|
176
|
+
if (logins.length === 0) return refuse('identity-missing');
|
|
177
|
+
if (logins.length > 1) return refuse('identity-ambiguous');
|
|
178
|
+
let login = logins[0].trim();
|
|
179
|
+
if (!LOGIN.test(login)) return refuse('identity-malformed');
|
|
180
|
+
login = login.toLowerCase();
|
|
181
|
+
const groups = Object.keys(perimeter.groups).filter((n) => perimeter.groups[n].members.includes(login)).sort(pyKeySort);
|
|
182
|
+
if (groups.length === 0) return refuse('not-in-perimeter', login);
|
|
183
|
+
const dashboards = new Set(groups.flatMap((n) => perimeter.groups[n].dashboards));
|
|
184
|
+
const capabilities = new Set(groups.flatMap((n) => perimeter.groups[n].capabilities));
|
|
185
|
+
if (surface === 'dashboard') {
|
|
186
|
+
if (!dashboards.has(target)) return refuse('dashboard-not-granted', login, groups);
|
|
187
|
+
return { allowed: true, status: 200, code: 'ok', login, groups };
|
|
188
|
+
}
|
|
189
|
+
const presented = bearer(headers);
|
|
190
|
+
if (presented === null) return refuse('key-required', login, groups);
|
|
191
|
+
const digest = hashKey(presented);
|
|
192
|
+
const key = (keys ?? []).find((k) => sameDigest(k.sha256, digest));
|
|
193
|
+
if (!key) return refuse('key-unknown', login, groups);
|
|
194
|
+
if (key.login !== login) return refuse('key-not-yours', login, groups);
|
|
195
|
+
if (!key.capabilities.every((c) => capabilities.has(c))) return refuse('key-wider-than-group', login, groups);
|
|
196
|
+
if (!key.capabilities.includes(target)) return refuse('capability-not-granted', login, groups);
|
|
197
|
+
return { allowed: true, status: 200, code: 'ok', login, groups, key: key.id };
|
|
198
|
+
}
|
|
@@ -16,6 +16,12 @@ PROTOCOL = "fabric-service/0.1"
|
|
|
16
16
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
# DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
|
|
20
|
+
# responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
|
|
21
|
+
# and the board kit never enrolls a service's responder for it (FAA-244).
|
|
22
|
+
PRIMARY_CAPABILITY = "project.work"
|
|
23
|
+
|
|
24
|
+
|
|
19
25
|
# Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
|
|
20
26
|
# contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
|
|
21
27
|
# off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -52,6 +52,9 @@ class BoardSession:
|
|
|
52
52
|
timeout=timeout if timeout is not None and timeout > 0 else self.timeout,
|
|
53
53
|
method=method, protocol_version=self.protocol_version if self.mcp_session else None)
|
|
54
54
|
except _hub.HubError as exc:
|
|
55
|
+
if exc.code == "foreign-user": # FAA-252: another OS account's process holds the port; nothing was sent
|
|
56
|
+
raise BoardError("foreign-user", "The hub's port is held by another OS account's process; nothing was sent.",
|
|
57
|
+
True) from exc
|
|
55
58
|
raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
|
|
56
59
|
"the same idempotency key.", True) from exc
|
|
57
60
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -148,6 +148,9 @@ class Responder(SlotKeeper):
|
|
|
148
148
|
caps = list(capabilities)
|
|
149
149
|
if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
|
|
150
150
|
raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
|
|
151
|
+
if _fs.PRIMARY_CAPABILITY in caps: # FAA-244: DEC-0140 PR-1, a session's slot, never a service's
|
|
152
|
+
raise BoardError("invalid_arguments", "%s is a Project's primary responder's, a session's: a service answers "
|
|
153
|
+
"the capabilities it declares (DEC-0140 PR-1)." % _fs.PRIMARY_CAPABILITY)
|
|
151
154
|
self.hub, self.project = hub, _hub._project(project_id)
|
|
152
155
|
#: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
|
|
153
156
|
self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
|
|
@@ -258,7 +261,7 @@ class Responder(SlotKeeper):
|
|
|
258
261
|
status = self._status()
|
|
259
262
|
if missing:
|
|
260
263
|
self._emit("board.access.needed", "warning", "The operator has not let this service answer %s on this Project; ask in "
|
|
261
|
-
"Fabric (
|
|
264
|
+
"Fabric (Settings → Agent access)." % ", ".join(missing), notify=True, episode=",".join(missing),
|
|
262
265
|
capabilities=missing)
|
|
263
266
|
self.slots = {}
|
|
264
267
|
self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
|
|
@@ -430,7 +433,8 @@ class Responder(SlotKeeper):
|
|
|
430
433
|
try:
|
|
431
434
|
self._receipt("com.reply", self._call("com.reply", dict(self._reply_extra(entry), fence=self._fence(old, probe["key"]))))
|
|
432
435
|
except BoardError as exc:
|
|
433
|
-
if exc.code in ("unreachable", "session-ended", "authority_changed", "not_authorized", "not_available",
|
|
436
|
+
if exc.code in ("unreachable", "foreign-user", "session-ended", "authority_changed", "not_authorized", "not_available",
|
|
437
|
+
"capacity_exceeded"): # foreign-user: nothing was sent now, so nothing is known (0.79.0 seam)
|
|
434
438
|
raise
|
|
435
439
|
else:
|
|
436
440
|
entry["replied"] = True
|
|
@@ -549,7 +553,7 @@ class Responder(SlotKeeper):
|
|
|
549
553
|
self.state = "not-authorized"
|
|
550
554
|
self._drop_session()
|
|
551
555
|
self._emit("board.access.needed", "warning", "This service may not answer requests for this Project: the operator "
|
|
552
|
-
"has to allow it in Fabric (
|
|
556
|
+
"has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.", notify=True,
|
|
553
557
|
episode="access", reason=code)
|
|
554
558
|
elif code == "held":
|
|
555
559
|
self.state = "paused"
|
|
@@ -564,9 +568,13 @@ class Responder(SlotKeeper):
|
|
|
564
568
|
else:
|
|
565
569
|
self.state = "down"
|
|
566
570
|
# In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
|
|
567
|
-
if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol"
|
|
571
|
+
if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol",
|
|
572
|
+
"foreign-user")) and not keep:
|
|
568
573
|
self._drop_session()
|
|
569
|
-
|
|
574
|
+
# FAA-252: a port another OS account holds is said as such; nothing was sent there
|
|
575
|
+
self._emit("board.down", "warning", "Fabric's board port is held by another OS account's process; nothing was sent, "
|
|
576
|
+
"and the responder tries again with a growing pause." if code == "foreign-user" else
|
|
577
|
+
"Fabric's board cannot be reached; the responder tries again with a growing pause.",
|
|
570
578
|
episode="down", reason=code)
|
|
571
579
|
return wait
|
|
572
580
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -20,7 +20,7 @@ HOLD_RETRY_MAX_SECONDS = 900.0
|
|
|
20
20
|
#: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
|
|
21
21
|
SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
|
|
22
22
|
"schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
|
|
23
|
-
"not-a-hub", "hub-unavailable", "protocol", "not_available"])
|
|
23
|
+
"not-a-hub", "hub-unavailable", "protocol", "not_available", "foreign-user"])
|
|
24
24
|
|
|
25
25
|
_whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
|
|
26
26
|
|
|
@@ -78,7 +78,7 @@ class SlotKeeper:
|
|
|
78
78
|
if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
|
|
79
79
|
rest = {"project": self.project, "generation": generation}
|
|
80
80
|
except BoardError as exc:
|
|
81
|
-
if exc.code in ("unreachable", "session-ended"):
|
|
81
|
+
if exc.code in ("unreachable", "session-ended", "foreign-user"): # foreign-user: the earlier ask is unknown
|
|
82
82
|
raise
|
|
83
83
|
if exc.code == "idempotency_window_expired":
|
|
84
84
|
rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
|
|
|
45
45
|
jcs,
|
|
46
46
|
)
|
|
47
47
|
from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
|
|
48
|
+
import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
|
|
48
49
|
from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
|
|
49
50
|
|
|
50
51
|
REFUSAL_CODES = (
|
|
@@ -672,9 +673,21 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
672
673
|
return None
|
|
673
674
|
|
|
674
675
|
|
|
675
|
-
|
|
676
|
+
class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
677
|
+
"""Plain http, which a key set source allows on loopback only (DEC-0154 HA-2, FAA-255): every connect, a reconnect
|
|
678
|
+
included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's process,
|
|
679
|
+
so keys another account's listener serves are never read; built the way urllib builds a connection."""
|
|
680
|
+
|
|
681
|
+
def __init__(self, host: str, port: Optional[int] = None, timeout: Any = 10.0, **kwargs: Any) -> None:
|
|
682
|
+
http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
|
|
683
|
+
self.expect_pid = None
|
|
684
|
+
self.vouch_pid = None # an issuer's URL is never a process this caller started (FAA-267)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _opener(deadline: Dict[str, Any], direct: bool = False) -> urllib.request.OpenerDirector:
|
|
676
688
|
"""No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
|
|
677
|
-
issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13).
|
|
689
|
+
issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13). `direct`:
|
|
690
|
+
a loopback source never goes through a proxy from the environment, so the owner check reads the URL's own port."""
|
|
678
691
|
def watched(base: type) -> type:
|
|
679
692
|
class Watched(base): # type: ignore[misc, valid-type]
|
|
680
693
|
def connect(self) -> None:
|
|
@@ -695,11 +708,13 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
|
|
|
695
708
|
|
|
696
709
|
class Http(urllib.request.HTTPHandler):
|
|
697
710
|
def http_open(self, req: Any) -> Any:
|
|
698
|
-
return self.do_open(watched(
|
|
711
|
+
return self.do_open(watched(_CheckedHTTPConnection), req) # http is loopback only (the source check)
|
|
699
712
|
|
|
700
713
|
class Https(urllib.request.HTTPSHandler):
|
|
701
714
|
def https_open(self, req: Any) -> Any:
|
|
702
715
|
return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
|
|
716
|
+
if direct:
|
|
717
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
|
|
703
718
|
return urllib.request.build_opener(_NoRedirect, Http, Https)
|
|
704
719
|
|
|
705
720
|
|
|
@@ -709,7 +724,8 @@ def _fetch(source: str) -> Any:
|
|
|
709
724
|
request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
|
|
710
725
|
deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
|
|
711
726
|
try:
|
|
712
|
-
|
|
727
|
+
direct = source.lower().startswith("http://")
|
|
728
|
+
with _opener(deadline, direct).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
|
|
713
729
|
body = b""
|
|
714
730
|
while len(body) <= 65536 and not deadline["fired"]:
|
|
715
731
|
chunk = response.read1(65537 - len(body))
|
|
@@ -724,6 +740,8 @@ def _fetch(source: str) -> Any:
|
|
|
724
740
|
raise
|
|
725
741
|
except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
|
|
726
742
|
raise ConnectionError(type(exc).__name__) from None
|
|
743
|
+
except _po.ListenerRefused as exc: # FAA-255: another account's listener, or one that cannot be told
|
|
744
|
+
raise ConnectionError("foreign-user: %s" % exc) from None
|
|
727
745
|
finally:
|
|
728
746
|
if deadline["timer"] is not None:
|
|
729
747
|
deadline["timer"].cancel()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -18,6 +18,7 @@ from pathlib import Path as _Path # noqa: E402
|
|
|
18
18
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
19
19
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
20
20
|
from fabric_base import (
|
|
21
|
+
PRIMARY_CAPABILITY,
|
|
21
22
|
PROTOCOL,
|
|
22
23
|
ServiceError,
|
|
23
24
|
_ID,
|
|
@@ -49,7 +50,7 @@ from fabric_usage import (
|
|
|
49
50
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
53
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -628,6 +629,11 @@ def configuration_problems(doc: Any) -> List[str]:
|
|
|
628
629
|
return problems
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
#: FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
|
|
633
|
+
_RESERVED = ("%%s: %s is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never "
|
|
634
|
+
"serves it as a tool or declares it." % PRIMARY_CAPABILITY)
|
|
635
|
+
|
|
636
|
+
|
|
631
637
|
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
632
638
|
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
633
639
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
@@ -664,6 +670,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
664
670
|
problems = a2a_surface_problems(surfaces["a2a"])
|
|
665
671
|
if problems:
|
|
666
672
|
raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
|
|
673
|
+
mcp_surface = surfaces.get("mcp")
|
|
674
|
+
listed = mcp_surface.get("capabilities") if isinstance(mcp_surface, dict) else None
|
|
675
|
+
if isinstance(listed, list) and PRIMARY_CAPABILITY in listed:
|
|
676
|
+
raise ServiceError(_RESERVED % "surfaces.mcp.capabilities")
|
|
667
677
|
doc: Dict[str, Any] = {
|
|
668
678
|
"protocol": PROTOCOL,
|
|
669
679
|
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
@@ -698,6 +708,9 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
698
708
|
"surfaces": surfaces})
|
|
699
709
|
if findings:
|
|
700
710
|
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
711
|
+
if any(isinstance(a, dict) and a.get("tool") == PRIMARY_CAPABILITY
|
|
712
|
+
for a in extensions[ACTIONS_EXTENSION_KEY].get("actions") or []):
|
|
713
|
+
raise ServiceError(_RESERVED % "an action's tool")
|
|
701
714
|
doc["extensions"] = dict(extensions)
|
|
702
715
|
if configuration is not None:
|
|
703
716
|
problems = configuration_problems(configuration)
|
|
@@ -73,8 +73,42 @@ class InstanceLock:
|
|
|
73
73
|
#: a file is allowed there).
|
|
74
74
|
WINDOWS_LOCK_OFFSET = 1 << 30
|
|
75
75
|
|
|
76
|
+
#: How many times acquire re-opens service.lock when the file it locked is no longer the file at its path (a
|
|
77
|
+
#: folder swapped under it by a data import), before it gives up with an error naming the path (FAA-293).
|
|
78
|
+
INODE_RETRIES = 3
|
|
79
|
+
|
|
76
80
|
def acquire(self) -> "InstanceLock":
|
|
77
81
|
ensure_private_dir(self.path.parent)
|
|
82
|
+
for _ in range(self.INODE_RETRIES):
|
|
83
|
+
if self._acquire_once():
|
|
84
|
+
return self
|
|
85
|
+
raise ServiceError("%s kept moving while it was locked (a folder swapped under the data folder?): no copy "
|
|
86
|
+
"holds a file that is no longer at its path; start again once the folder is settled." % self.path)
|
|
87
|
+
|
|
88
|
+
def _locked_file_is_at_path(self, fd: int) -> bool:
|
|
89
|
+
"""The inode this copy locked is the file at the path now (FAA-293). Windows does not rename a folder holding
|
|
90
|
+
an open file, and its lock is a byte range on this handle, so the check is POSIX only."""
|
|
91
|
+
if WINDOWS:
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
here, held = os.stat(str(self.path)), os.fstat(fd)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return False
|
|
97
|
+
return (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino)
|
|
98
|
+
|
|
99
|
+
def _let_go(self, fd: int) -> None:
|
|
100
|
+
try:
|
|
101
|
+
os.close(fd)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
name, self._name = self._name, None
|
|
105
|
+
if name is not None:
|
|
106
|
+
try:
|
|
107
|
+
_drop_lock_name(name)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def _acquire_once(self) -> bool:
|
|
78
112
|
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
|
|
79
113
|
try:
|
|
80
114
|
if WINDOWS:
|
|
@@ -92,6 +126,9 @@ class InstanceLock:
|
|
|
92
126
|
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
93
127
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
94
128
|
raise
|
|
129
|
+
if not self._locked_file_is_at_path(fd): # another copy may hold the file at the path: re-open that one
|
|
130
|
+
os.close(fd)
|
|
131
|
+
return False
|
|
95
132
|
try:
|
|
96
133
|
self._name = _hold_lock_name(self.path)
|
|
97
134
|
except BaseException:
|
|
@@ -114,8 +151,11 @@ class InstanceLock:
|
|
|
114
151
|
except OSError: # as with the close: nothing to add to the first error
|
|
115
152
|
pass
|
|
116
153
|
raise
|
|
154
|
+
if not self._locked_file_is_at_path(fd): # swapped while the pid was written: this copy holds nothing real
|
|
155
|
+
self._let_go(fd)
|
|
156
|
+
return False
|
|
117
157
|
self._fd = fd
|
|
118
|
-
return
|
|
158
|
+
return True
|
|
119
159
|
|
|
120
160
|
def release(self) -> None:
|
|
121
161
|
if self._fd is not None:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py
CHANGED
|
@@ -366,6 +366,10 @@ class Client:
|
|
|
366
366
|
if answer is not None:
|
|
367
367
|
answer["attempts"] = attempts
|
|
368
368
|
return answer
|
|
369
|
+
if outcome == "foreign-user": # FAA-257: the fallback would go to the same refused listener
|
|
370
|
+
raise OpenRouterError("The base URL's port is held by another OS account's process, or its owner "
|
|
371
|
+
"cannot be told; nothing was sent (DEC-0154 HA-2)", kind="foreign-user",
|
|
372
|
+
attempts=attempts)
|
|
369
373
|
if outcome in _STOP_AT_ONCE.values():
|
|
370
374
|
raise OpenRouterError("OpenRouter refused the key or the request (%s)" % status, kind=outcome,
|
|
371
375
|
attempts=attempts)
|
|
@@ -390,6 +394,8 @@ class Client:
|
|
|
390
394
|
# the provider may have billed a call whose answer never arrived whole (a cut connection looks the
|
|
391
395
|
# same as a refused one from here): counted, never as $0; over-counting is the safe side
|
|
392
396
|
self.ledger.record(self._unmeasured(asked, task_run_id, worst))
|
|
397
|
+
if err.kind == "foreign-user": # nothing was sent: nothing billed, and no other model there (FAA-257)
|
|
398
|
+
return "foreign-user", err.status, None, None
|
|
393
399
|
return ("retry" if err.kind in ("connection", "timeout") else "refused"), err.status, None, None
|
|
394
400
|
status = response.status
|
|
395
401
|
if status in _STOP_AT_ONCE:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -22,6 +22,7 @@ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa:
|
|
|
22
22
|
from fabric_base import ( # noqa: F401
|
|
23
23
|
PROTOCOL,
|
|
24
24
|
EXTENSION_KEY,
|
|
25
|
+
PRIMARY_CAPABILITY,
|
|
25
26
|
SUPERVISOR_ENV,
|
|
26
27
|
DRAIN_SECONDS,
|
|
27
28
|
_ID,
|
|
@@ -314,9 +315,14 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
314
315
|
launchd_plist,
|
|
315
316
|
_launchctl,
|
|
316
317
|
_domain,
|
|
318
|
+
SYSTEM_DAEMONS,
|
|
319
|
+
launchd_domain,
|
|
317
320
|
launchd_loaded,
|
|
318
321
|
launchd_pid,
|
|
319
322
|
fetch_well_known,
|
|
323
|
+
well_known_of,
|
|
324
|
+
job_well_known_of,
|
|
325
|
+
ForeignUserAnswer,
|
|
320
326
|
_OVERRIDE_ROW,
|
|
321
327
|
launchd_override,
|
|
322
328
|
launchd_install,
|