@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  52. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  54. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  73. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -0,0 +1,266 @@
1
+ // Work a service holds by its own limits, said to its caller and its operator (FAA-298): a held job's statusMessage in
2
+ // the contract's pause grammar (`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`; the
3
+ // pinned interop-job schema is closed, so `waiting {reason, since, until, message}` rides statusMessage, never a field
4
+ // of the job), one push per hold, and the operator's scoped lift. The twin of fabric_holds.py, held to the same
5
+ // vectors (test/fixtures/holds/waiting-vectors.json). Part of the Node service kit (layer L3, after fabric-interop).
6
+ // Node 20+, no dependency.
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import { ServiceError, nowIso } from './fabric-base.mjs';
10
+ import { atomicWrite } from './fabric-paths.mjs';
11
+ import { decisionShape } from './fabric-actions.mjs';
12
+ import { QUOTA_EXHAUSTED, codePoints, pauseMessage, readJobPause, utcTime } from './fabric-interop-rules.mjs';
13
+ import { secretValue } from './fabric-secrets.mjs';
14
+
15
+ /** The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap. */
16
+ export const HOLD_REASONS = Object.freeze(['daily-quota', 'caller-share', 'concurrency', 'money']);
17
+ /** How the push names each reason to a person. */
18
+ export const REASON_WORDS = Object.freeze({
19
+ 'daily-quota': 'the daily quota', 'caller-share': 'its per-caller share',
20
+ concurrency: 'the concurrency cap', money: 'the money cap',
21
+ });
22
+ /** The push's kind: a decision for the operator (level notice, notify true). */
23
+ export const HOLD_EVENT_KIND = 'limit.held';
24
+ /** The record of a lift applied (level info, no notify). */
25
+ export const LIFT_EVENT_KIND = 'limit.lifted';
26
+ /** The decision action a lift carries (DEC-0121 AP-2 shape). */
27
+ export const LIFT_ACTION = 'limit.lift';
28
+ const MAX_STATUS_MESSAGE = 500;
29
+ const WAITING = /^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?$/;
30
+ const SIZES = ['jobs', 'usd', 'limit'];
31
+
32
+ const instant = (value, name) => {
33
+ if (!Number.isFinite(utcTime(value))) throw new ServiceError(`${name} is a UTC instant (Z, at most 3 fraction digits).`);
34
+ return value;
35
+ };
36
+ const reasonOk = (reason, what) => {
37
+ if (!HOLD_REASONS.includes(reason)) throw new ServiceError(`A ${what}'s reason is one of ${HOLD_REASONS.join(', ')}.`);
38
+ };
39
+ const squash = (text) => String(text).split(/\s+/).filter(Boolean).join(' ');
40
+
41
+ /** A held job's statusMessage; the message is shortened, never refused, to fit 500 characters. */
42
+ export function waitingMessage(reason, since, until = null, message = null) {
43
+ reasonOk(reason, 'hold');
44
+ instant(since, 'since');
45
+ if (until !== null && until !== undefined) instant(until, 'until');
46
+ else until = null;
47
+ let text = `waiting ${reason} since ${since}`;
48
+ const head = QUOTA_EXHAUSTED.length + (until === null ? 0 : ' until '.length + until.length) + ': '.length;
49
+ if (message !== null && message !== undefined) {
50
+ const squashed = squash(message);
51
+ const room = MAX_STATUS_MESSAGE - head - codePoints(text) - ': '.length;
52
+ if (squashed && room > 0) {
53
+ text += ': ' + (codePoints(squashed) <= room ? squashed : [...squashed].slice(0, room - 3).join('').trimEnd() + '...');
54
+ }
55
+ }
56
+ return pauseMessage(until === null ? { text } : { resetsAt: until, text });
57
+ }
58
+
59
+ /** `{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds, else null. */
60
+ export function readWaiting(answer) {
61
+ const pause = readJobPause(answer);
62
+ if (!pause.paused) return null;
63
+ let rest = answer.job.statusMessage.slice(QUOTA_EXHAUSTED.length);
64
+ if ('resetsAt' in pause) rest = rest.slice(' until '.length + pause.resetsAt.length);
65
+ if (!rest.startsWith(': ')) return null;
66
+ const match = WAITING.exec(rest.slice(2));
67
+ if (!match || !Number.isFinite(utcTime(match[2]))) return null;
68
+ const waiting = { reason: match[1], since: match[2] };
69
+ if ('resetsAt' in pause) waiting.until = pause.resetsAt;
70
+ if (match[3] !== undefined) waiting.message = match[3];
71
+ return waiting;
72
+ }
73
+
74
+ /** Put one job of a fabric-interop JobStore on hold (JobStore.pause with the waiting text). */
75
+ export function holdJob(jobs, jobId, reason, since, until = null, message = null) {
76
+ let text = waitingMessage(reason, since, until, message).slice(QUOTA_EXHAUSTED.length);
77
+ if (until !== null && until !== undefined) text = text.slice(' until '.length + until.length);
78
+ return jobs.pause(jobId, until === null || until === undefined ? { text: text.slice(2) } : { resetsAt: until, text: text.slice(2) });
79
+ }
80
+
81
+ /** The push's one sentence: who, what, why, when it frees by itself, and the question. */
82
+ export function holdSentence(caller, work, reason, until) {
83
+ const frees = until !== null && until !== undefined ? `it frees by itself at ${until}`
84
+ : (['concurrency', 'caller-share'].includes(reason) ? 'it frees by itself when a running job ends' : 'it does not free by itself');
85
+ return `${work} for ${caller} is held by ${REASON_WORDS[reason]}; ${frees}. Raise the limit for it?`;
86
+ }
87
+
88
+ function text(value, name, high = 200) {
89
+ if (typeof value !== 'string' || !value.trim() || value.length > high) throw new ServiceError(`${name} is a text of 1 to ${high} characters.`);
90
+ if (secretValue(value)) throw new ServiceError(`${name} looks like a credential; a hold names callers and work, never a secret.`);
91
+ return squash(value);
92
+ }
93
+
94
+ const newId = () => crypto.randomUUID().replaceAll('-', '');
95
+
96
+ /**
97
+ * The holds a service's limits put on work and the operator's lifts, in one file (0600, written atomically).
98
+ * `events`, when given, is a function `({kind, level, text, subject, notify}) => event` that writes the push and the
99
+ * lift record to the service's event log; without it they are returned for the service to write.
100
+ */
101
+ export class HoldBook {
102
+ constructor(file, { events = null, clock = nowIso } = {}) {
103
+ this.file = file;
104
+ this.events = events;
105
+ this.clock = clock;
106
+ }
107
+
108
+ load() {
109
+ if (!fs.existsSync(this.file)) return { holds: [], lifts: [] };
110
+ let data;
111
+ try {
112
+ data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
113
+ } catch {
114
+ throw new ServiceError('The hold book is not JSON; it is kept as it is.');
115
+ }
116
+ if (data === null || typeof data !== 'object' || !Array.isArray(data.holds) || !Array.isArray(data.lifts)) {
117
+ throw new ServiceError('The hold book is not {holds, lifts}; it is kept as it is.');
118
+ }
119
+ return data;
120
+ }
121
+
122
+ save(data) { atomicWrite(this.file, JSON.stringify(data, null, 1) + '\n', 0o600); }
123
+
124
+ now(now) { return instant(now ?? this.clock(), 'now'); }
125
+
126
+ emit(kind, level, sentence, subject, notify) {
127
+ const event = { kind, level, text: sentence, subject };
128
+ if (notify) event.notify = true;
129
+ return this.events ? this.events(event) : event;
130
+ }
131
+
132
+ static expire(data, at) {
133
+ for (const hold of data.holds) {
134
+ if (hold.closed === null && hold.until !== null && utcTime(hold.until) <= at) {
135
+ hold.closed = 'expired';
136
+ hold.closedAt = hold.until;
137
+ }
138
+ }
139
+ }
140
+
141
+ static live(lift, at) {
142
+ if (lift.from !== null && lift.from !== undefined && utcTime(lift.from) > at) return false;
143
+ if (lift.until !== null && lift.until !== undefined && utcTime(lift.until) <= at) return false;
144
+ return (lift.left ?? 1) > 0;
145
+ }
146
+
147
+ /** Hold `work` for `caller` by `reason`: {hold, statusMessage, push}; push only when this call opened the hold. */
148
+ hold({ caller, work, reason, until = null, message = null, jobId = null, now = null }) {
149
+ caller = text(caller, 'caller');
150
+ work = text(work, 'work');
151
+ reasonOk(reason, 'hold');
152
+ const at = this.now(now);
153
+ if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) {
154
+ throw new ServiceError(`until ${until} has passed: the limit no longer holds the work.`);
155
+ }
156
+ const data = this.load();
157
+ HoldBook.expire(data, utcTime(at));
158
+ let hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
159
+ const opened = hold === undefined;
160
+ if (opened) {
161
+ hold = { id: newId(), reason, caller, work, since: at, until, jobs: [], closed: null };
162
+ data.holds.push(hold);
163
+ }
164
+ if (jobId !== null && !hold.jobs.includes(String(jobId))) hold.jobs.push(String(jobId));
165
+ this.save(data);
166
+ const statusMessage = waitingMessage(reason, hold.since, hold.until, message);
167
+ const push = opened ? this.emit(HOLD_EVENT_KIND, 'notice', holdSentence(caller, work, reason, until),
168
+ { type: 'limit', id: `${reason}:${caller}`.slice(0, 200), label: REASON_WORDS[reason] }, true) : null;
169
+ return { hold: { ...hold }, statusMessage, push };
170
+ }
171
+
172
+ /** Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again. */
173
+ release({ reason, caller, now = null }) {
174
+ const at = this.now(now);
175
+ const data = this.load();
176
+ const hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
177
+ if (!hold) return null;
178
+ hold.closed = 'released';
179
+ hold.closedAt = at;
180
+ this.save(data);
181
+ return { ...hold };
182
+ }
183
+
184
+ openHolds(now = null) {
185
+ const data = this.load();
186
+ HoldBook.expire(data, utcTime(this.now(now)));
187
+ return data.holds.filter((h) => h.closed === null).map((h) => ({ ...h }));
188
+ }
189
+
190
+ /** Record the operator's answer as a scoped lift: exactly one of jobs, usd or limit (limit needs until). */
191
+ lift({ reason, decision, caller = null, jobId = null, jobs = null, usd = null, limit = null, until = null, now = null }) {
192
+ reasonOk(reason, 'lift');
193
+ if (!decisionShape(decision) || decision.action !== LIFT_ACTION) {
194
+ throw new ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
195
+ + "words verbatim): operatorDecision({ action: 'limit.lift', instruction: words }).");
196
+ }
197
+ const sizes = { jobs, usd, limit };
198
+ const given = SIZES.filter((name) => sizes[name] !== null && sizes[name] !== undefined);
199
+ if (given.length !== 1) throw new ServiceError('A lift is one of jobs, usd or limit.');
200
+ const kind = given[0];
201
+ const size = sizes[kind];
202
+ if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || (kind !== 'usd' && !Number.isInteger(size))) {
203
+ throw new ServiceError(`A lift's ${kind} is a positive ${kind === 'usd' ? 'number' : 'integer'}.`);
204
+ }
205
+ const at = this.now(now);
206
+ if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) throw new ServiceError("A lift's window ends after now.");
207
+ if (kind === 'limit' && until === null) {
208
+ throw new ServiceError('A raised limit needs its window\'s end (until): it falls back when the window ends.');
209
+ }
210
+ const record = { id: newId(), reason, caller: caller === null ? null : text(caller, 'caller'),
211
+ job: jobId === null ? null : String(jobId), from: at, until, [kind]: size, decision: { ...decision }, at };
212
+ if (kind !== 'limit') record.left = size;
213
+ const data = this.load();
214
+ HoldBook.expire(data, utcTime(at));
215
+ for (const hold of data.holds) {
216
+ if (hold.closed === null && hold.reason === reason && (record.caller === null || record.caller === hold.caller)
217
+ && (jobId === null || hold.jobs.includes(String(jobId)))) {
218
+ hold.closed = 'lifted';
219
+ hold.closedAt = at;
220
+ hold.lift = record.id;
221
+ }
222
+ }
223
+ data.lifts.push(record);
224
+ this.save(data);
225
+ const scope = record.caller ? `for ${record.caller}` : 'for every caller';
226
+ const amount = { jobs: `${size} more job(s)`, usd: `USD ${size} more`, limit: `the limit at ${size}` }[kind];
227
+ this.emit(LIFT_EVENT_KIND, 'info', `The operator lifted ${REASON_WORDS[reason]} ${scope}: ${amount}${until ? ` until ${until}` : ''}.`,
228
+ { type: 'limit', id: `${reason}:${record.caller ?? '*'}`, label: REASON_WORDS[reason] }, false);
229
+ return { ...record };
230
+ }
231
+
232
+ static matching(data, reason, caller, jobId, at, kind) {
233
+ return data.lifts.filter((l) => l.reason === reason && kind in l && HoldBook.live(l, at)
234
+ && (l.caller === null || l.caller === caller) && (l.job === null || l.job === jobId));
235
+ }
236
+
237
+ /** Whether a lift lets work past the limit: {admitted: true, lift, left} spends one job or `costUsd`, else {admitted: false}. */
238
+ admit({ reason, caller, jobId = null, costUsd = null, now = null }) {
239
+ const at = utcTime(this.now(now));
240
+ const data = this.load();
241
+ const kind = costUsd !== null ? 'usd' : 'jobs';
242
+ const found = HoldBook.matching(data, reason, caller, jobId === null ? null : String(jobId), at, kind)
243
+ .sort((a, b) => ((a.job === null) - (b.job === null)) || ((a.caller === null) - (b.caller === null)));
244
+ for (const lift of found) {
245
+ const need = kind === 'usd' ? costUsd : 1;
246
+ if (lift.left >= need) {
247
+ lift.left = kind === 'usd' ? Math.round((lift.left - need) * 1e6) / 1e6 : lift.left - 1;
248
+ this.save(data);
249
+ return { admitted: true, lift: lift.id, left: lift.left };
250
+ }
251
+ }
252
+ return { admitted: false };
253
+ }
254
+
255
+ /** The limit in force: `base`, or the highest live raised limit for (reason, caller). */
256
+ limit({ reason, base, caller = null, now = null }) {
257
+ const at = utcTime(this.now(now));
258
+ return Math.max(base, ...HoldBook.matching(this.load(), reason, caller, null, at, 'limit').map((l) => l.limit));
259
+ }
260
+
261
+ /** Every lift with `live`: whether it still applies now. */
262
+ lifts(now = null) {
263
+ const at = utcTime(this.now(now));
264
+ return this.load().lifts.map((l) => ({ ...l, live: HoldBook.live(l, at) }));
265
+ }
266
+ }
@@ -20,7 +20,8 @@
20
20
  // Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
21
21
 
22
22
  import http from 'node:http';
23
- import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
23
+ import net from 'node:net';
24
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED, UNKNOWN, method as tableMethod, owner as ownerOf, refusal, serverEnd } from './fabric-peer-owner.mjs';
24
25
 
25
26
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
26
27
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
@@ -158,6 +159,61 @@ function checkHeaders(headers) {
158
159
 
159
160
  const LOOPBACK_AGENT = new CheckedAgent();
160
161
 
162
+ // Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
163
+ // spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0` and `[::ffff:127.0.0.1]` arrive here (FAA-260).
164
+ export function peerIsLoopback(address) {
165
+ const a = String(address ?? '').split('%', 1)[0].toLowerCase();
166
+ if (a === '::1' || a === '0.0.0.0' || a === '::') return true;
167
+ const v4 = /^(?:::ffff:)?(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(a);
168
+ if (v4) return v4[1] === '127';
169
+ const hex = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(a);
170
+ return hex !== null && (parseInt(hex[1], 16) >> 8) === 127;
171
+ }
172
+
173
+ // An http.Agent for plain http to a host not spelled as loopback (`insecure`): when the connected socket's peer is a
174
+ // loopback address, its server end is told as CheckedAgent tells it and refused before a byte is written unless it is
175
+ // this OS account's process (DEC-0154 HA-2; 0.74.0 unit readings 7 and 8, FAA-260). Another peer is used as it is.
176
+ export class PeerCheckedAgent extends http.Agent {
177
+ constructor({ ask = {}, ...options } = {}) {
178
+ super(options);
179
+ this.ask = ask;
180
+ }
181
+
182
+ createConnection(options, callback) {
183
+ let done = false;
184
+ const finish = (err, socket) => { if (!done) { done = true; callback(err, socket); } };
185
+ const port = Number(options.port);
186
+ const socket = net.connect({ host: options.host, port, noDelay: true, autoSelectFamily: true,
187
+ ...(options.lookup ? { lookup: options.lookup } : {}) });
188
+ socket.once('error', (err) => finish(err));
189
+ socket.once('connect', async () => {
190
+ if (!peerIsLoopback(socket.remoteAddress)) {
191
+ socket.removeAllListeners('error');
192
+ finish(null, socket);
193
+ return;
194
+ }
195
+ socket.pause();
196
+ let who;
197
+ try {
198
+ who = await serverEnd(socket, this.ask);
199
+ } catch (err) {
200
+ who = ownerOf(UNKNOWN, { method: tableMethod(), reason: `check: ${err?.name ?? 'Error'}` });
201
+ }
202
+ const refused = refusal(who, port, null);
203
+ if (refused) {
204
+ socket.destroy();
205
+ finish(new ListenerRefused(refused[0], `${refused[1]}; nothing was sent`, who));
206
+ return;
207
+ }
208
+ socket.removeAllListeners('error');
209
+ socket.resume();
210
+ finish(null, socket);
211
+ });
212
+ }
213
+ }
214
+
215
+ const PLAIN_AGENT = new PeerCheckedAgent();
216
+
161
217
  // Plain http to a loopback host (DEC-0154 HA-2): node:http over a connection checked before a byte is written, no
162
218
  // redirect followed, the body read to one byte past maxBytes. fetch cannot hand its socket to the check.
163
219
  export function checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes, agent = LOOPBACK_AGENT }) {
@@ -198,12 +254,15 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
198
254
  });
199
255
  }
200
256
 
201
- // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http to a
202
- // loopback host goes through checkedLoopbackTransport.
257
+ // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http goes
258
+ // through checkedLoopbackTransport, its connection checked when it reaches loopback (FAA-260).
203
259
  export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
204
260
  const target = new URL(url);
205
- if (target.protocol === 'http:' && isLoopback(target.hostname)) {
206
- return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes });
261
+ if (target.protocol === 'http:') {
262
+ // plain http never goes through fetch: a URL spelled as loopback is checked on every connection, any other is
263
+ // checked when its connected peer is a loopback address (FAA-260)
264
+ return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes,
265
+ ...(isLoopback(target.hostname) ? {} : { agent: PLAIN_AGENT }) });
207
266
  }
208
267
  const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
209
268
  signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
@@ -509,9 +509,71 @@ const FOLDER_MESSAGES = {
509
509
  'provider-manifest': 'provider-manifest: the provider manifest must be a JSON object, and when it carries costPresets fabric.package.json must carry the same costPresets (copy them; pack does not)',
510
510
  };
511
511
 
512
+ /**
513
+ * The agent's source manifest (contract DEC-0149, `fabric-source/1`): when the folder carries one, `pack` writes
514
+ * `secrets[]` from its secret slots and `state[]` from its store slots' paths, and FAC-SEM-160 holds them equal.
515
+ */
516
+ export const SOURCE_FILE = 'fabric.source.json';
517
+
518
+ function sourceManifest(files) {
519
+ if (!files.has(SOURCE_FILE)) return undefined;
520
+ const source = parseStrict(files.get(SOURCE_FILE), SOURCE_FILE);
521
+ if (source.format !== 'fabric-source/1') throw new PackageError(`${SOURCE_FILE} is not a fabric-source/1 manifest (its format member).`);
522
+ return source;
523
+ }
524
+
525
+ /** `secrets[]` from the secret slots (name, purpose, optional) and `state[]` from the store slots (path, purpose). */
526
+ export function sourceMembers(source) {
527
+ const secrets = [];
528
+ const state = [];
529
+ for (const slot of objects(source.slots)) {
530
+ if (slot.kind === 'secret' && isString(slot.name)) secrets.push({ name: slot.name, purpose: slot.purpose, ...(slot.optional === true ? { optional: true } : {}) });
531
+ else if (slot.kind === 'store' && isString(slot.path)) state.push({ path: slot.path, purpose: slot.purpose });
532
+ }
533
+ return { secrets, state };
534
+ }
535
+
536
+ /**
537
+ * FAC-SEM-160 (contract `sourcePackageFindings`): the package's secrets[] are the source's secret slots, by name and
538
+ * optional; its state[] paths are the store slots' paths when state[] holds any entry.
539
+ */
540
+ export function sourcePackageFindings(source, pkg) {
541
+ if (!isObject(source) || !isObject(pkg)) return [];
542
+ const out = [];
543
+ const flag = (instancePath, message) => out.push({ code: 'FAC-SEM-160', instancePath, message });
544
+ const slots = objects(source.slots);
545
+ const secrets = new Map(slots.filter((s) => s.kind === 'secret' && isString(s.name)).map((s) => [s.name, s.optional === true]));
546
+ const stores = new Set(slots.filter((s) => s.kind === 'store' && isString(s.path)).map((s) => s.path));
547
+ const packaged = (Array.isArray(pkg.secrets) ? pkg.secrets : []).flatMap((e, i) => (isObject(e) ? [[e, i]] : []));
548
+ const names = new Set(packaged.map(([e]) => e.name));
549
+ for (const name of secrets.keys()) if (!names.has(name)) flag('/package/secrets', `the source's secret slot ${name} is not in the package's secrets[]`);
550
+ for (const [entry, index] of packaged) {
551
+ if (!isString(entry.name) || !secrets.has(entry.name)) flag(`/package/secrets/${index}/name`, `secrets[${index}] is no secret slot of the source`);
552
+ else if (secrets.get(entry.name) !== (entry.optional === true)) flag(`/package/secrets/${index}/optional`, `secrets[${index}] is ${secrets.get(entry.name) ? '' : 'not '}optional in the source`);
553
+ }
554
+ if (!Array.isArray(pkg.state) || pkg.state.length === 0) return out;
555
+ const paths = new Set(objects(pkg.state).map((e) => e.path));
556
+ for (const p of stores) if (!paths.has(p)) flag('/package/state', "a store slot's path of the source is not in the package's state[]");
557
+ pkg.state.forEach((entry, index) => {
558
+ if (isObject(entry) && (!isString(entry.path) || !stores.has(entry.path))) flag(`/package/state/${index}/path`, `state[${index}] is no store slot of the source`);
559
+ });
560
+ return out;
561
+ }
562
+
563
+ function sourceProblems(pkg, files) {
564
+ if (!files.has(SOURCE_FILE)) return [];
565
+ try {
566
+ return sourcePackageFindings(sourceManifest(files), pkg).map((p) => ({ ...p, rule: p.code }));
567
+ } catch (error) {
568
+ if (!(error instanceof PackageError)) throw error;
569
+ return [{ rule: 'source', instancePath: '/files', message: error.message }];
570
+ }
571
+ }
572
+
512
573
  function allProblems(pkg, files) {
513
574
  return [
514
575
  ...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
576
+ ...sourceProblems(pkg, files),
515
577
  ...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
516
578
  ...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
517
579
  ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: FOLDER_MESSAGES[p.problem] ?? p.problem })),
@@ -525,6 +587,21 @@ function writeAtomic(file, text) {
525
587
  fs.renameSync(temp, file);
526
588
  }
527
589
 
590
+ /** The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order. */
591
+ function secretsLacking(draft, secrets) {
592
+ const have = new Set(secrets.map((s) => s.name));
593
+ const lacking = [];
594
+ for (const entry of Array.isArray(draft.secrets) ? draft.secrets : []) {
595
+ const name = isObject(entry) ? entry.name : undefined;
596
+ if (isString(name) && !have.has(name) && !lacking.includes(name)) lacking.push(name);
597
+ }
598
+ return lacking;
599
+ }
600
+
601
+ function lackingMessage(lacking) {
602
+ return `${SOURCE_FILE} declares no secret slot for ${lacking.join(', ')}, which ${PACKAGE_FILE}'s secrets[] names: pack writes secrets[] from the manifest's slots and never drops a declared secret. Add each as a slot of kind secret to ${SOURCE_FILE} (or take it out of secrets[]), then pack again`;
603
+ }
604
+
528
605
  /** Pack `folder`; with `apply` and no problem, fabric.package.json is rewritten. */
529
606
  export function pack(folder, { apply = false } = {}) {
530
607
  const files = readFolder(folder);
@@ -532,8 +609,28 @@ export function pack(folder, { apply = false } = {}) {
532
609
  throw new PackageError(`Write ${PACKAGE_FILE} in the folder first: schema, kind, id, version, publisher, licence and, for an agent, resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest.`);
533
610
  }
534
611
  const draft = parseStrict(files.get(PACKAGE_FILE), PACKAGE_FILE);
612
+ const source = sourceManifest(files);
613
+ const written = [];
614
+ const refused = [];
615
+ if (source !== undefined) {
616
+ // DEC-0149: secrets[] and state[] come from the source manifest's slots; an empty state[] is a copy without state.
617
+ const members = sourceMembers(source);
618
+ const lacking = secretsLacking(draft, members.secrets);
619
+ if (lacking.length > 0) {
620
+ // a declared secret is never dropped: the manifest gains its slot first
621
+ refused.push({ rule: 'source', instancePath: '/secrets', message: lackingMessage(lacking) });
622
+ } else if (members.secrets.length > 0 || Object.hasOwn(draft, 'secrets')) {
623
+ Object.defineProperty(draft, 'secrets', { value: members.secrets, enumerable: true, writable: true, configurable: true });
624
+ written.push('secrets[]');
625
+ }
626
+ if (members.state.length > 0 && !(Array.isArray(draft.state) && draft.state.length === 0)) {
627
+ Object.defineProperty(draft, 'state', { value: members.state, enumerable: true, writable: true, configurable: true });
628
+ written.push('state[]');
629
+ }
630
+ }
535
631
  const { pkg, notes } = compose(draft, files);
536
- const problems = allProblems(pkg, files);
632
+ if (written.length > 0) notes.push(`${written.join(' and ')} written from ${SOURCE_FILE}`);
633
+ const problems = [...refused, ...allProblems(pkg, files)];
537
634
  const suite = isObject(pkg.evals) ? pkg.evals.suite : undefined;
538
635
  if (isString(suite) && files.has(suite) && problems.some((p) => p.instancePath === '/evals/receipt/suite')) notes.push(`the suite packed is ${sha256Of(files.get(suite))}: run it again and write that receipt`);
539
636
  const answer = { ok: problems.length === 0, applied: false, digest: pkg.digest, files: pkg.files.length, problems, notes };
@@ -45,7 +45,7 @@ import http from 'node:http';
45
45
  import net from 'node:net';
46
46
  import os from 'node:os';
47
47
 
48
- export const PEER_OWNER_VERSION = '1.1.0+kit.4'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
48
+ export const PEER_OWNER_VERSION = '1.2.0+kit.5'; // the Python twin's, Observatory's 1.2.0 with the kit's differences
49
49
  export const THIS_ACCOUNT = 'this-account';
50
50
  export const ANOTHER_ACCOUNT = 'another-account';
51
51
  export const SYSTEM = 'system';
@@ -420,7 +420,7 @@ async function ownerOf(local, remote, opts = {}, server = false) {
420
420
  // Linux: a connection not yet accept()ed is printed with uid 0 and inode 0; it belongs to the listener on its local
421
421
  // address and dies with it, so that listener answers. An accepted connection's own row carries the uid of the
422
422
  // process that accepted it: it answers itself, so a listener swapped after another account accepted the client
423
- // cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's pending CT-254, FAA-258).
423
+ // cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's CT-254, released).
424
424
  // A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so nothing
425
425
  // sent to it reaches anyone (0.74.0 unit reading 6).
426
426
  return oneOwner(listenersOf(table.rows, found[0].local), table.how, opts, 'not-accepted');
@@ -464,7 +464,7 @@ export function peer(socket, opts) {
464
464
  // For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
465
465
  // address is this socket's own, the server process's accepted socket, so a listener swapped after listen cannot pass
466
466
  // for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
467
- // answers (the contract's pending CT-254, FAA-258).
467
+ // answers (the contract's CT-254, HA-2's Linux source).
468
468
  export function serverEnd(socket, opts) {
469
469
  if (!socket.remoteAddress || !socket.localAddress) return Promise.resolve(owner(UNKNOWN, { reason: 'socket: not connected' }));
470
470
  return ownerOf(address(socket.remoteAddress, socket.remotePort), address(socket.localAddress, socket.localPort), opts, true);
@@ -12,7 +12,7 @@ export const PROTOCOL_REVISIONS = new Map(Object.entries({
12
12
  'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-machine-report': ['1'], 'fabric-ops-digest': ['0.1'], 'fabric-operator-channel': ['0.1'],
13
13
  'fabric-package': ['1'],
14
14
  'fabric-peer-share': ['1'], 'fabric-peer-share-envelope': ['1'], 'fabric-peer-share-report': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
15
- 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'], 'machine-report': ['1'], mandate: ['1'],
15
+ 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], 'fabric-source': ['1'], heartbeat: ['1'], 'learning-note': ['1'], 'machine-report': ['1'], mandate: ['1'],
16
16
  memory: ['0.1'], 'node-summary': ['1'], 'operator-source': ['1'], 'org-adaptation': ['1'], 'org-adaptation-receipt': ['1'], 'org-authority': ['1'], 'org-authority-draft': ['1'],
17
17
  'org-key-set': ['1'], 'org-key-set-draft': ['1'], 'org-membership': ['1'], 'org-root-registration': ['1'], 'pairing-message': ['1'],
18
18
  'peer-share-deletion': ['1'], 'peer-share-frame': ['1'], 'peer-share-revocation': ['1'], pipeline: ['0.1'], 'repair-request': ['1'], 'repair-result': ['1'],
@@ -38,8 +38,12 @@ export function credentialName(name) {
38
38
 
39
39
  /** A JWT or JWS anywhere in a value. */
40
40
  export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
41
- /** A value that says it is a credential, or a provider key's shape. */
42
- export const SECRET_VALUE = /(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})/i;
41
+ /** A value that says it is a credential (`Bearer ` at its start, in any case), or a provider key's shape with its prefix
42
+ * in its canonical case only, at the start of a value, after a character that is no ASCII letter or digit, or after an
43
+ * escape; an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more,
44
+ * within 256 characters (the contract's SECRET_VALUE at the pin, DEC-0151). A match includes its boundary character:
45
+ * callers test, never read the matched text or its index. */
46
+ export const SECRET_VALUE = /(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)(?:sk-(?=[A-Za-z0-9_-]{0,255}?\d)(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))/;
43
47
  const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
44
48
  export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
45
49
 
@@ -91,7 +95,10 @@ export function secretUrl(value) {
91
95
  const REPOSITORY_URI = /^([A-Za-z][A-Za-z0-9+.-]*:(?:\/\/[^/?#]*)?)([^?#]*)([\s\S]*)$/;
92
96
  const REPOSITORY_NAME = /^(?:[a-z0-9._-]+|[A-Za-z._-]+)$/; // lower case, or letters with no digit (readings 8)
93
97
  const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
94
- const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${SECRET_VALUE.source})`, 'i');
98
+ // A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
99
+ // boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
100
+ const REPOSITORY_KEYS = 'sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}';
101
+ const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${REPOSITORY_KEYS})`);
95
102
  const JWT_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))${JWT_SHAPE.source}`);
96
103
  const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
97
104
 
@@ -107,6 +107,10 @@ export {
107
107
  STATUSES,
108
108
  PLACEMENTS,
109
109
  MANAGERS,
110
+ PEER_CHECKS,
111
+ connectionRefusal,
112
+ connectionOwner,
113
+ loopbackServer,
110
114
  descriptorPath,
111
115
  readDescriptors,
112
116
  validateDescriptor,
@@ -185,6 +189,7 @@ export {
185
189
  taskUninstall,
186
190
  launchdPlist,
187
191
  SYSTEM_DAEMONS,
192
+ USER_NAME_RULE,
188
193
  launchdDomain,
189
194
  wellKnownOf,
190
195
  jobWellKnownOf,