@passioncode-ai/fabric-agent-adapter 0.44.0 → 0.44.2

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 (27) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +9 -6
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +13 -11
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +5 -11
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +8 -8
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +263 -67
  25. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -11,51 +11,37 @@ import path from 'node:path';
11
11
  import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
12
12
  import { jcs } from './fabric-delegation.mjs';
13
13
 
14
- export const PROTOCOL = 'fabric-interop/0.1';
15
- export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
16
- export const MCP_REVISION = '2026-07-28';
17
- export const TERMINAL = ['completed', 'failed', 'cancelled'];
18
- export const CONTRACT_VERSION = '0.1.0';
19
- const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
20
- const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
21
- // The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
22
- export const JOB_HANDLE_SCHEMA = {
23
- type: 'object', required: ['job'], additionalProperties: false,
24
- properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
25
- properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
26
- };
27
-
28
- const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
29
- const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
30
- const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
31
- const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
32
- const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
33
-
34
- export class InteropError extends ServiceError {}
35
- export class UnknownJob extends InteropError {
36
- constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
37
- }
38
-
39
- // --- C3.4 trace context ---------------------------------------------------------------
40
- export function parseTraceparent(value) {
41
- const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
42
- if (!m) return null;
43
- const [, version, traceId, spanId, flags] = m;
44
- if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
45
- return { version, traceId, spanId, flags };
46
- }
47
-
48
- const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
49
-
50
- export function childTraceparent(parent) {
51
- const p = parseTraceparent(parent);
52
- return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
53
- }
54
-
55
- export const traceIds = (traceparent) => {
56
- const p = parseTraceparent(traceparent);
57
- return p ? { traceId: p.traceId, spanId: p.spanId } : {};
58
- };
14
+ export {
15
+ PROTOCOL,
16
+ EXTENSION_KEY,
17
+ MCP_REVISION,
18
+ TERMINAL,
19
+ CONTRACT_VERSION,
20
+ JOB_HANDLE_SCHEMA,
21
+ InteropError,
22
+ UnknownJob,
23
+ parseTraceparent,
24
+ childTraceparent,
25
+ traceIds,
26
+ QUOTA_EXHAUSTED,
27
+ OUTCOME_UNKNOWN,
28
+ LIMIT_REACHED,
29
+ INTEROP_NULL_USAGE,
30
+ PROTOCOLS_READ,
31
+ utcTime,
32
+ quotaDataOk,
33
+ readJobPause,
34
+ jobCallerAction,
35
+ intentIdempotencyKey,
36
+ readsNullUsage,
37
+ requestMeta,
38
+ usageFindings,
39
+ usageToward,
40
+ jobLimitRules,
41
+ pauseMessage,
42
+ repeatedKeyDecision,
43
+ } from './fabric-interop-rules.mjs';
44
+ import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
59
45
 
60
46
  // --- C3.1 capabilities as tools ----------------------------------------------------------
61
47
  export function expectedAnnotations(effect, idempotency) {
@@ -95,22 +81,6 @@ export const unknownJobResult = (jobId, traceparent) =>
95
81
  toolResult({ error: { code: 'unknown-job', message: `No job ${jobId} here.` } }, { isError: true, traceparent });
96
82
 
97
83
  // --- C3.2 the result envelope --------------------------------------------------------------
98
- function checkUsage(usage, nulls = false) {
99
- if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
100
- for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
101
- for (const [key, value] of Object.entries(usage)) {
102
- if (nulls && key === 'partial') {
103
- if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
104
- continue;
105
- }
106
- if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
107
- if (nulls && value === null) continue;
108
- const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
109
- if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
110
- }
111
- if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
112
- return { ...usage };
113
- }
114
84
 
115
85
  // The full result envelope (contract result.schema.json, DEC-0017): the shape a synchronous call returns.
116
86
  export function resultEnvelope({ outcome, done, proof, scope, notVerified, output, usage, producer, artifacts = [], traceparent, id, createdAt, requestMeta }) {
@@ -129,199 +99,6 @@ export function resultEnvelope({ outcome, done, proof, scope, notVerified, outpu
129
99
  return envelope;
130
100
  }
131
101
 
132
- // --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
133
- // #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
134
- // The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
135
- // fabric_interop.py. Every function here answers and never throws on malformed input.
136
-
137
- export const QUOTA_EXHAUSTED = 'quota-exhausted';
138
- export const OUTCOME_UNKNOWN = 'outcome-unknown';
139
- export const LIMIT_REACHED = 'limit-reached';
140
- export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
141
- /** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
142
- export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
143
- const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
144
- const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
145
- const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
146
- const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
147
- const MAX_STATUS_MESSAGE = 500;
148
- const MAX_ERROR_MESSAGE = 2000;
149
- const codePoints = (value) => [...value].length;
150
- import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
151
- import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
152
- const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
153
-
154
- /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
155
- export function utcTime(value) {
156
- const m = isString(value) ? UTC_INSTANT.exec(value) : null;
157
- if (!m) return Number.NaN;
158
- const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
159
- const date = new Date(0);
160
- date.setUTCFullYear(year, month - 1, day);
161
- if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
162
- date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
163
- return date.getTime();
164
- }
165
-
166
- export function quotaDataOk(data) {
167
- const retryable = own(data, 'retryable');
168
- if (!isObject(data) || typeof retryable !== 'boolean') return false;
169
- if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
170
- if (!Object.hasOwn(data, 'resetsAt')) return true;
171
- return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
172
- }
173
-
174
- function jobOf(answer) {
175
- const job = own(answer, 'job');
176
- const id = own(job, 'id');
177
- if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
178
- return job;
179
- }
180
-
181
- export function readJobPause(answer) {
182
- const job = jobOf(answer);
183
- const message = own(job, 'statusMessage');
184
- if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
185
- const match = PAUSE.exec(message);
186
- if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
187
- const until = match[1];
188
- if (until === undefined) return { paused: true };
189
- return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
190
- }
191
-
192
- /** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
193
- export function jobCallerAction(answer, now) {
194
- const at = utcTime(now);
195
- const job = jobOf(answer);
196
- if (!Number.isFinite(at) || !job) return { action: 'invalid' };
197
- const status = own(job, 'status');
198
- if (status === 'working') {
199
- const pause = readJobPause(answer);
200
- if ('malformed' in pause) return { action: 'invalid' };
201
- if (!pause.paused) return { action: 'poll' };
202
- if (pause.resetsAt === undefined) return { action: 'wait' };
203
- return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
204
- }
205
- if (status === 'input_required') return { action: 'answer' };
206
- if (status === 'completed') return { action: 'done' };
207
- if (status === 'cancelled') return { action: 'cancelled' };
208
- const error = own(job, 'error');
209
- const code = own(error, 'code');
210
- const message = own(error, 'message');
211
- const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
212
- const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
213
- if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
214
- if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
215
- if (code === LIMIT_REACHED) return { action: 'person' };
216
- if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
217
- const data = own(error, 'data');
218
- if (!quotaDataOk(data)) return { action: 'invalid' };
219
- if (!data.retryable) return { action: 'person' };
220
- return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
221
- }
222
-
223
- /** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
224
- export function intentIdempotencyKey(intent) {
225
- const names = ['agentId', 'capability', 'input', 'request'];
226
- if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
227
- const [agentId, capability, input, request] = names.map((name) => own(intent, name));
228
- if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
229
- if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
230
- let form;
231
- try {
232
- form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
233
- } catch {
234
- return undefined;
235
- }
236
- const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
237
- bytes[6] = (bytes[6] & 0x0f) | 0x80;
238
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
239
- const hex = bytes.toString('hex');
240
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
241
- }
242
-
243
- export function readsNullUsage(requestMeta) {
244
- const protocols = own(requestMeta, 'protocols');
245
- return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
246
- }
247
-
248
- /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
249
- export function requestMeta(traceparent) {
250
- const meta = { protocols: [...PROTOCOLS_READ] };
251
- if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
252
- return meta;
253
- }
254
-
255
- /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
256
- export function usageFindings(usage, at) {
257
- if (!isObject(usage) || usage.partial !== false) return [];
258
- const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
259
- if (unknown.length === 0) return [];
260
- return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
261
- }
262
-
263
- /** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
264
- export function usageToward(usage, meta) {
265
- if (readsNullUsage(meta)) {
266
- const checked = checkUsage(usage, true);
267
- if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
268
- return checked;
269
- }
270
- if (!isObject(usage)) throw new InteropError('usage must be an object.');
271
- return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
272
- }
273
-
274
- function jobFindings(value) {
275
- const findings = [];
276
- if ('malformed' in readJobPause(value)) {
277
- findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
278
- }
279
- const error = own(own(value, 'job'), 'error');
280
- const data = own(error, 'data');
281
- if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
282
- findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
283
- }
284
- findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
285
- return findings;
286
- }
287
-
288
- const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
289
-
290
- /** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
291
- export function jobLimitRules(kind, value) {
292
- if (kind === 'interop-job') return jobFindings(value);
293
- if (kind === 'usage') return usageFindings(value, '');
294
- if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
295
- if (kind === 'job-caller') {
296
- const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
297
- return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
298
- }
299
- return undefined;
300
- }
301
-
302
- /** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
303
- export function pauseMessage({ resetsAt, text } = {}) {
304
- const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
305
- if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
306
- throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
307
- }
308
- return message;
309
- }
310
-
311
- /** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
312
- export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
313
- if (!isObject(job) || job.status !== 'failed') return 'same';
314
- const error = own(job, 'error');
315
- const code = own(error, 'code');
316
- if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
317
- if (code !== QUOTA_EXHAUSTED) return 'same';
318
- if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
319
- const data = own(error, 'data');
320
- const at = utcTime(now);
321
- if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
322
- return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
323
- }
324
- // #endregion job-limits-kit-node
325
102
 
326
103
  // --- C3.3 awaiting a choice ------------------------------------------------------------------
327
104
  export function formRequest(message, properties, required) {
@@ -0,0 +1,100 @@
1
+ // fabric-package/1, the schema reading: the package schema bundled from the contract at the pin, read by a JSON
2
+ // Schema 2020-12 subset with JavaScript's patterns, lengths in code points and the formats the contract uses.
3
+ // fabric-package.mjs checks, packs, signs and verifies on it and re-exports schemaProblems. Part of the Node service
4
+ // kit (FAA-92, split from fabric-package.mjs; layer L2); the twin of fabric_package_schema.py. Node 20+, no dependency.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { isString, jsonEqual } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
9
+
10
+ export const SCHEMA_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fabric-package.schema.json');
11
+
12
+ // --- the schema (bundled from the contract at the pin) ---------------------------------------------------------------
13
+
14
+ export let schemaCache;
15
+ export const schema = () => (schemaCache ??= JSON.parse(fs.readFileSync(SCHEMA_FILE, 'utf8')));
16
+
17
+
18
+ export const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v);
19
+ export const DATE_TIME = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
20
+ export const MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
21
+ export const leap = (year) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
22
+
23
+ export function formatOk(format, value) {
24
+ if (!isString(value)) return true;
25
+ if (format === 'date-time') {
26
+ const m = DATE_TIME.exec(value);
27
+ if (!m) return false;
28
+ const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
29
+ // The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29 holds).
30
+ // Counted here, not with Date.UTC, which reads years 0 to 99 as 1900 to 1999.
31
+ if (mo < 1 || mo > 12 || d < 1 || d > (mo === 2 && leap(y) ? 29 : MONTH_DAYS[mo]) || h > 23 || mi > 59 || s > 59) return false;
32
+ return m[8] === 'Z' || m[8] === 'z' || (Number(m[9]) < 24 && Number(m[10]) < 60);
33
+ }
34
+ if (format === 'uri') return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value);
35
+ return true;
36
+ }
37
+
38
+ export const pointer = (token) => String(token).replace(/~/g, '~0').replace(/\//g, '~1');
39
+ export const codePoints = (s) => [...s].length; // JSON Schema counts code points, not UTF-16 units
40
+
41
+ export function validate(node, value, at, out) {
42
+ if (node === true) return;
43
+ if (node === false) { out.push({ instancePath: at, message: 'is not allowed here' }); return; }
44
+ if (node.$ref) {
45
+ const target = node.$ref.replace(/^#\//, '').split('/').reduce((n, t) => n[t.replace(/~1/g, '/').replace(/~0/g, '~')], schema());
46
+ validate(target, value, at, out);
47
+ }
48
+ const kind = typeOf(value);
49
+ if (node.type !== undefined) {
50
+ const allowed = Array.isArray(node.type) ? node.type : [node.type];
51
+ if (!(allowed.includes(kind) || (kind === 'integer' && allowed.includes('number')))) { out.push({ instancePath: at, message: `must be ${allowed.join(' or ')}` }); return; }
52
+ }
53
+ if ('const' in node && !jsonEqual(node.const, value)) out.push({ instancePath: at, message: `must be ${JSON.stringify(node.const)}` });
54
+ if (node.enum && !node.enum.some((item) => jsonEqual(item, value))) out.push({ instancePath: at, message: `must be one of ${node.enum.map((i) => JSON.stringify(i)).join(', ')}` });
55
+ if (kind === 'string') {
56
+ const length = codePoints(value);
57
+ if (length < (node.minLength ?? 0)) out.push({ instancePath: at, message: `is shorter than ${node.minLength}` });
58
+ if (node.maxLength !== undefined && length > node.maxLength) out.push({ instancePath: at, message: `is longer than ${node.maxLength}` });
59
+ if (node.pattern !== undefined && !new RegExp(node.pattern, 'u').test(value)) out.push({ instancePath: at, message: `does not match ${node.pattern}` });
60
+ if (node.format !== undefined && !formatOk(node.format, value)) out.push({ instancePath: at, message: `is not a ${node.format}` });
61
+ }
62
+ if ((kind === 'integer' || kind === 'number') && node.minimum !== undefined && value < node.minimum) out.push({ instancePath: at, message: `is below ${node.minimum}` });
63
+ if (kind === 'array') {
64
+ if (value.length < (node.minItems ?? 0)) out.push({ instancePath: at, message: `has fewer than ${node.minItems} items` });
65
+ if (node.maxItems !== undefined && value.length > node.maxItems) out.push({ instancePath: at, message: `has more than ${node.maxItems} items` });
66
+ if (node.uniqueItems && value.some((item, i) => value.slice(0, i).some((other) => jsonEqual(item, other)))) out.push({ instancePath: at, message: 'has duplicate items' });
67
+ if (node.items !== undefined) value.forEach((item, i) => validate(node.items, item, `${at}/${i}`, out));
68
+ if (node.contains !== undefined) {
69
+ const matches = value.filter((item) => problemsOf(node.contains, item).length === 0).length;
70
+ if (matches < (node.minContains ?? 1)) out.push({ instancePath: at, message: `has fewer than ${node.minContains ?? 1} matching items` });
71
+ if (node.maxContains !== undefined && matches > node.maxContains) out.push({ instancePath: at, message: `has more than ${node.maxContains} matching items` });
72
+ }
73
+ }
74
+ if (kind === 'object') {
75
+ for (const name of node.required ?? []) if (!Object.hasOwn(value, name)) out.push({ instancePath: at, message: `must have ${name}` });
76
+ const properties = node.properties ?? {};
77
+ for (const [name, member] of Object.entries(value)) {
78
+ const where = `${at}/${pointer(name)}`;
79
+ if (Object.hasOwn(properties, name)) validate(properties[name], member, where, out);
80
+ else if (node.additionalProperties === false) out.push({ instancePath: where, message: 'is not a member of this object' });
81
+ else if (node.additionalProperties !== undefined) validate(node.additionalProperties, member, where, out);
82
+ if (node.propertyNames !== undefined) validate(node.propertyNames, name, where, out);
83
+ }
84
+ }
85
+ for (const sub of node.allOf ?? []) validate(sub, value, at, out);
86
+ if (node.not !== undefined && problemsOf(node.not, value).length === 0) out.push({ instancePath: at, message: 'must not match' });
87
+ if (node.if !== undefined) {
88
+ const branch = problemsOf(node.if, value).length === 0 ? node.then : node.else;
89
+ if (branch !== undefined) validate(branch, value, at, out);
90
+ }
91
+ }
92
+
93
+ export function problemsOf(node, value) {
94
+ const out = [];
95
+ validate(node, value, '', out);
96
+ return out;
97
+ }
98
+
99
+ /** Where the package breaks fabric-package.schema.json (the contract's, bundled at the pin). */
100
+ export const schemaProblems = (pkg) => problemsOf(schema(), pkg);
@@ -24,7 +24,6 @@ export const PLUGIN_MANIFEST = '.claude-plugin/plugin.json';
24
24
  export const SIGNATURE_STATEMENT = 'fabric-package-signature/1';
25
25
  /** DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm. */
26
26
  export const PACKAGE_ALGORITHMS = Object.freeze(['EdDSA']);
27
- const SCHEMA_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fabric-package.schema.json');
28
27
  // What pack refuses to list: version-control and cache litter, and files that are credentials by their name.
29
28
  const JUNK = /^(\.git|__pycache__|\.DS_Store|.*\.pyc)$/;
30
29
  const SECRET_FILE = /^(\.env(\..+)?|.*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc))$/;
@@ -125,95 +124,9 @@ export function readSigningKey(file, kid) {
125
124
  return { seed, kid };
126
125
  }
127
126
 
128
- // --- the schema (bundled from the contract at the pin) ---------------------------------------------------------------
127
+ export { schemaProblems } from './fabric-package-schema.mjs'; // the schema reading (FAA-92)
128
+ import { pointer, schema, schemaProblems } from './fabric-package-schema.mjs';
129
129
 
130
- let schemaCache;
131
- const schema = () => (schemaCache ??= JSON.parse(fs.readFileSync(SCHEMA_FILE, 'utf8')));
132
-
133
-
134
- const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v);
135
- const DATE_TIME = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
136
- const MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
137
- const leap = (year) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
138
-
139
- function formatOk(format, value) {
140
- if (!isString(value)) return true;
141
- if (format === 'date-time') {
142
- const m = DATE_TIME.exec(value);
143
- if (!m) return false;
144
- const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
145
- // The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29 holds).
146
- // Counted here, not with Date.UTC, which reads years 0 to 99 as 1900 to 1999.
147
- if (mo < 1 || mo > 12 || d < 1 || d > (mo === 2 && leap(y) ? 29 : MONTH_DAYS[mo]) || h > 23 || mi > 59 || s > 59) return false;
148
- return m[8] === 'Z' || m[8] === 'z' || (Number(m[9]) < 24 && Number(m[10]) < 60);
149
- }
150
- if (format === 'uri') return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value);
151
- return true;
152
- }
153
-
154
- const pointer = (token) => String(token).replace(/~/g, '~0').replace(/\//g, '~1');
155
- const codePoints = (s) => [...s].length; // JSON Schema counts code points, not UTF-16 units
156
-
157
- function validate(node, value, at, out) {
158
- if (node === true) return;
159
- if (node === false) { out.push({ instancePath: at, message: 'is not allowed here' }); return; }
160
- if (node.$ref) {
161
- const target = node.$ref.replace(/^#\//, '').split('/').reduce((n, t) => n[t.replace(/~1/g, '/').replace(/~0/g, '~')], schema());
162
- validate(target, value, at, out);
163
- }
164
- const kind = typeOf(value);
165
- if (node.type !== undefined) {
166
- const allowed = Array.isArray(node.type) ? node.type : [node.type];
167
- if (!(allowed.includes(kind) || (kind === 'integer' && allowed.includes('number')))) { out.push({ instancePath: at, message: `must be ${allowed.join(' or ')}` }); return; }
168
- }
169
- if ('const' in node && !jsonEqual(node.const, value)) out.push({ instancePath: at, message: `must be ${JSON.stringify(node.const)}` });
170
- if (node.enum && !node.enum.some((item) => jsonEqual(item, value))) out.push({ instancePath: at, message: `must be one of ${node.enum.map((i) => JSON.stringify(i)).join(', ')}` });
171
- if (kind === 'string') {
172
- const length = codePoints(value);
173
- if (length < (node.minLength ?? 0)) out.push({ instancePath: at, message: `is shorter than ${node.minLength}` });
174
- if (node.maxLength !== undefined && length > node.maxLength) out.push({ instancePath: at, message: `is longer than ${node.maxLength}` });
175
- if (node.pattern !== undefined && !new RegExp(node.pattern, 'u').test(value)) out.push({ instancePath: at, message: `does not match ${node.pattern}` });
176
- if (node.format !== undefined && !formatOk(node.format, value)) out.push({ instancePath: at, message: `is not a ${node.format}` });
177
- }
178
- if ((kind === 'integer' || kind === 'number') && node.minimum !== undefined && value < node.minimum) out.push({ instancePath: at, message: `is below ${node.minimum}` });
179
- if (kind === 'array') {
180
- if (value.length < (node.minItems ?? 0)) out.push({ instancePath: at, message: `has fewer than ${node.minItems} items` });
181
- if (node.maxItems !== undefined && value.length > node.maxItems) out.push({ instancePath: at, message: `has more than ${node.maxItems} items` });
182
- if (node.uniqueItems && value.some((item, i) => value.slice(0, i).some((other) => jsonEqual(item, other)))) out.push({ instancePath: at, message: 'has duplicate items' });
183
- if (node.items !== undefined) value.forEach((item, i) => validate(node.items, item, `${at}/${i}`, out));
184
- if (node.contains !== undefined) {
185
- const matches = value.filter((item) => problemsOf(node.contains, item).length === 0).length;
186
- if (matches < (node.minContains ?? 1)) out.push({ instancePath: at, message: `has fewer than ${node.minContains ?? 1} matching items` });
187
- if (node.maxContains !== undefined && matches > node.maxContains) out.push({ instancePath: at, message: `has more than ${node.maxContains} matching items` });
188
- }
189
- }
190
- if (kind === 'object') {
191
- for (const name of node.required ?? []) if (!Object.hasOwn(value, name)) out.push({ instancePath: at, message: `must have ${name}` });
192
- const properties = node.properties ?? {};
193
- for (const [name, member] of Object.entries(value)) {
194
- const where = `${at}/${pointer(name)}`;
195
- if (Object.hasOwn(properties, name)) validate(properties[name], member, where, out);
196
- else if (node.additionalProperties === false) out.push({ instancePath: where, message: 'is not a member of this object' });
197
- else if (node.additionalProperties !== undefined) validate(node.additionalProperties, member, where, out);
198
- if (node.propertyNames !== undefined) validate(node.propertyNames, name, where, out);
199
- }
200
- }
201
- for (const sub of node.allOf ?? []) validate(sub, value, at, out);
202
- if (node.not !== undefined && problemsOf(node.not, value).length === 0) out.push({ instancePath: at, message: 'must not match' });
203
- if (node.if !== undefined) {
204
- const branch = problemsOf(node.if, value).length === 0 ? node.then : node.else;
205
- if (branch !== undefined) validate(branch, value, at, out);
206
- }
207
- }
208
-
209
- function problemsOf(node, value) {
210
- const out = [];
211
- validate(node, value, '', out);
212
- return out;
213
- }
214
-
215
- /** Where the package breaks fabric-package.schema.json (the contract's, bundled at the pin). */
216
- export const schemaProblems = (pkg) => problemsOf(schema(), pkg);
217
130
 
218
131
  // --- the contract's rules (FAC-SEM-064…066, FAC-SEM-102), ported line for line from src/package-rules.ts --------------
219
132
 
@@ -1,5 +1,5 @@
1
1
  // Where a service's things live (fabric-service/0.1): the services directory, its data, config, logs and run dirs per OS, private dirs, atomic writes.
2
- // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_paths.py. Node 20+, no dependency.
2
+ // Part of the Node service kit (FAA-65, layer L1); the twin of fabric_paths.py. Node 20+, no dependency.
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
@@ -51,19 +51,21 @@ export function atomicWrite(file, data, mode = 0o600) {
51
51
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
52
52
  const fd = fs.openSync(tmp, 'wx', mode);
53
53
  try {
54
- // On Windows the file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in —
55
- // so it does not depend on what the folder hands down. A rename keeps it.
56
- if (process.platform === 'win32') windowsProtect(tmp, { folder: false });
57
- fs.writeSync(fd, data);
58
- fs.fsyncSync(fd);
59
- } finally {
60
- fs.closeSync(fd);
61
- }
62
- try {
54
+ try {
55
+ // On Windows the file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in —
56
+ // so it does not depend on what the folder hands down. A rename keeps it.
57
+ if (process.platform === 'win32') windowsProtect(tmp, { folder: false });
58
+ fs.writeSync(fd, data);
59
+ fs.fsyncSync(fd);
60
+ } finally {
61
+ fs.closeSync(fd);
62
+ }
63
63
  if (process.platform !== 'win32') fs.chmodSync(tmp, mode);
64
64
  fs.renameSync(tmp, file);
65
65
  } catch (error) {
66
- fs.rmSync(tmp, { force: true });
66
+ // Whatever failed, no half-written file is left beside the real one, and a failed cleanup never replaces the
67
+ // error that caused it (FAA-85).
68
+ try { fs.rmSync(tmp, { force: true }); } catch { /* the original error is the one to report */ }
67
69
  throw error;
68
70
  }
69
71
  }
@@ -1,5 +1,5 @@
1
1
  // Windows, where the kit needs it (DEC-0032): reading a file's ACL to refuse a token file others can read.
2
- // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_platform.py. Node 20+, no dependency.
2
+ // Part of the Node service kit (FAA-65, layer L1); the twin of fabric_platform.py. Node 20+, no dependency.
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
@@ -1,4 +1,4 @@
1
- """What every module of the service kit shares (FAA-65, layer L3): the protocol name, ServiceError, the
1
+ """What every module of the service kit shares (FAA-65, layer L1): the protocol name, ServiceError, the
2
2
  platform, ids and now_iso. Standard library only."""
3
3
  from __future__ import annotations
4
4
 
@@ -21,10 +21,8 @@ import http.client
21
21
  import ipaddress
22
22
  import json
23
23
  import math
24
- import os
25
24
  from pathlib import Path
26
25
  import re
27
- import secrets
28
26
  import threading
29
27
  import time
30
28
  from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
@@ -46,6 +44,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
46
44
  MISSING as _MISSING,
47
45
  jcs,
48
46
  )
47
+ from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
49
48
 
50
49
  REFUSAL_CODES = (
51
50
  "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
@@ -484,7 +483,7 @@ class JtiStoreError(RuntimeError):
484
483
 
485
484
 
486
485
  class FileJtiStore:
487
- """Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). One
486
+ """Consumed approval ids in an owner-only JSON file, so a second use is refused across a restart (DL-18). One
488
487
  service instance writes it (the kit's instance lock keeps other processes out); threads are serialised."""
489
488
 
490
489
  _locks: Dict[str, threading.Lock] = {}
@@ -511,14 +510,9 @@ class FileJtiStore:
511
510
  if jti in seen:
512
511
  return False
513
512
  seen[jti] = expires_at
514
- self.path.parent.mkdir(parents=True, exist_ok=True)
515
- temp = self.path.with_name(".%s.%s.tmp" % (self.path.name, secrets.token_hex(4)))
516
- fd = os.open(str(temp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
517
- with os.fdopen(fd, "w") as handle:
518
- json.dump(seen, handle)
519
- handle.flush()
520
- os.fsync(handle.fileno())
521
- os.replace(str(temp), str(self.path))
513
+ # The kit's private writer: owner-only (0600; on Windows a protected ACL before the bytes, DEC-0032),
514
+ # fsynced, renamed into place, and no temporary file left when it fails (FAA-85).
515
+ _atomic_write(self.path, json.dumps(seen).encode("utf-8"))
522
516
  return True
523
517
 
524
518