@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.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +13 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +5 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +263 -67
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|