@vfarcic/dot-ai 1.22.0 → 1.23.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.
- package/dist/core/user-prompts-loader.d.ts +120 -1
- package/dist/core/user-prompts-loader.d.ts.map +1 -1
- package/dist/core/user-prompts-loader.js +522 -57
- package/dist/interfaces/mcp.d.ts +22 -0
- package/dist/interfaces/mcp.d.ts.map +1 -1
- package/dist/interfaces/mcp.js +118 -4
- package/dist/interfaces/openapi-generator.d.ts.map +1 -1
- package/dist/interfaces/openapi-generator.js +11 -5
- package/dist/interfaces/rest-api.d.ts +18 -4
- package/dist/interfaces/rest-api.d.ts.map +1 -1
- package/dist/interfaces/rest-api.js +138 -9
- package/dist/interfaces/routes/index.d.ts.map +1 -1
- package/dist/interfaces/routes/index.js +35 -0
- package/dist/interfaces/schemas/common.d.ts +33 -0
- package/dist/interfaces/schemas/common.d.ts.map +1 -1
- package/dist/interfaces/schemas/common.js +20 -1
- package/dist/interfaces/schemas/index.d.ts +2 -2
- package/dist/interfaces/schemas/index.d.ts.map +1 -1
- package/dist/interfaces/schemas/index.js +14 -3
- package/dist/interfaces/schemas/prompts.d.ts +155 -0
- package/dist/interfaces/schemas/prompts.d.ts.map +1 -1
- package/dist/interfaces/schemas/prompts.js +134 -1
- package/dist/tools/prompts.d.ts.map +1 -1
- package/dist/tools/prompts.js +37 -2
- package/package.json +6 -4
|
@@ -46,6 +46,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
46
46
|
};
|
|
47
47
|
})();
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.UserPromptsOverrideError = exports.IngestedSourceNotFoundError = exports.PromptsSourceValidationError = exports.MAX_INGESTED_SOURCES = void 0;
|
|
50
|
+
exports.sanitizeIngestFileMode = sanitizeIngestFileMode;
|
|
51
|
+
exports.ingestPromptsSource = ingestPromptsSource;
|
|
49
52
|
exports.getUserPromptsConfig = getUserPromptsConfig;
|
|
50
53
|
exports.computePromptsSource = computePromptsSource;
|
|
51
54
|
exports.getUserPromptsConfigFromOverride = getUserPromptsConfigFromOverride;
|
|
@@ -54,6 +57,8 @@ exports.sanitizeUrlForLogging = sanitizeUrlForLogging;
|
|
|
54
57
|
exports.scrubSourceUrl = scrubSourceUrl;
|
|
55
58
|
exports.loadUserPrompts = loadUserPrompts;
|
|
56
59
|
exports.clearUserPromptsCache = clearUserPromptsCache;
|
|
60
|
+
exports.clearIngestedPromptsSources = clearIngestedPromptsSources;
|
|
61
|
+
exports.getIngestedPromptsSources = getIngestedPromptsSources;
|
|
57
62
|
exports.getUserPromptsCacheState = getUserPromptsCacheState;
|
|
58
63
|
const fs = __importStar(require("fs"));
|
|
59
64
|
const path = __importStar(require("path"));
|
|
@@ -63,6 +68,393 @@ const git_utils_1 = require("./git-utils");
|
|
|
63
68
|
const prompts_1 = require("../tools/prompts");
|
|
64
69
|
// In-memory cache state (persists across requests within same process)
|
|
65
70
|
let cacheState = null;
|
|
71
|
+
const ingestedSources = new Map();
|
|
72
|
+
/**
|
|
73
|
+
* PRD #647 D5 — upload-input hardening caps. These are the EXACT values the
|
|
74
|
+
* frozen contract (.dot-agent-deck/647-contract.md) and the integration suite
|
|
75
|
+
* pin, chosen to trip the app-level cap before the ~1 MiB nginx ingress limit.
|
|
76
|
+
*/
|
|
77
|
+
const MAX_INGEST_FILES = 100;
|
|
78
|
+
const MAX_INGEST_TOTAL_BYTES = 256 * 1024; // 256 KiB
|
|
79
|
+
/**
|
|
80
|
+
* PRD #647 F5 / D2-M4 — max number of distinct ingested sources held in memory.
|
|
81
|
+
* The cache is push-populated by authenticated uploads, so without a bound it
|
|
82
|
+
* grows unbounded across distinct identifiers (a memory-growth vector). This
|
|
83
|
+
* is a simple access-ordered LRU cap: on overflow the least-recently-used entry
|
|
84
|
+
* (oldest `uploadedAt`, refreshed on every successful render) is evicted, and a
|
|
85
|
+
* later `?source=` render of an evicted identifier hits the existing D2
|
|
86
|
+
* render-miss guidance (re-upload required). Correctness over tuning per the
|
|
87
|
+
* frozen contract — 50 distinct sources is ample for the CLI's per-host usage.
|
|
88
|
+
*/
|
|
89
|
+
exports.MAX_INGESTED_SOURCES = 50;
|
|
90
|
+
/**
|
|
91
|
+
* Thrown by ingestPromptsSource on a malformed/unsafe upload so the REST handler
|
|
92
|
+
* can map it to a 400 (vs. a 500 for unexpected IO failures).
|
|
93
|
+
*/
|
|
94
|
+
class PromptsSourceValidationError extends Error {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = 'PromptsSourceValidationError';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
exports.PromptsSourceValidationError = PromptsSourceValidationError;
|
|
101
|
+
/**
|
|
102
|
+
* PRD #647 D2 — thrown when a render names an ingested `?source=<identifier>`
|
|
103
|
+
* that is not (or no longer) cached. It carries actionable re-upload guidance
|
|
104
|
+
* and is mapped to a 400 VALIDATION_ERROR by the REST handler. Deliberately
|
|
105
|
+
* distinct from the generic "Prompt not found" so the caller knows to re-upload
|
|
106
|
+
* the source rather than wonder why a known skill name is missing — and it never
|
|
107
|
+
* triggers (nor mentions) a git clone, since ingested identifiers are never
|
|
108
|
+
* cloned.
|
|
109
|
+
*/
|
|
110
|
+
class IngestedSourceNotFoundError extends Error {
|
|
111
|
+
constructor(message) {
|
|
112
|
+
super(message);
|
|
113
|
+
this.name = 'IngestedSourceNotFoundError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.IngestedSourceNotFoundError = IngestedSourceNotFoundError;
|
|
117
|
+
/**
|
|
118
|
+
* PRD #647 D5 — sanitize an untrusted POSIX `mode` string from an uploaded
|
|
119
|
+
* manifest into a safe numeric file mode. Strips the special bits
|
|
120
|
+
* (setuid 04000, setgid 02000, sticky 01000) so an uploaded skill file can never
|
|
121
|
+
* carry an exec-escalation surprise, and keeps only the standard rwx permission
|
|
122
|
+
* bits (07777 → 0777). Anything unparseable falls back to a sane 0644.
|
|
123
|
+
*/
|
|
124
|
+
function sanitizeIngestFileMode(mode) {
|
|
125
|
+
const DEFAULT_MODE = 0o644;
|
|
126
|
+
if (typeof mode !== 'string' || mode.trim() === '') {
|
|
127
|
+
return DEFAULT_MODE;
|
|
128
|
+
}
|
|
129
|
+
// Manifests send octal strings (e.g. "0644", "755"); parse base 8.
|
|
130
|
+
const parsed = parseInt(mode.trim(), 8);
|
|
131
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
132
|
+
return DEFAULT_MODE;
|
|
133
|
+
}
|
|
134
|
+
// Mask off setuid/setgid/sticky and any bits above the 0777 permission range.
|
|
135
|
+
return parsed & 0o777;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* PRD #647 C5 (CodeRabbit) — strict canonical-base64 matcher. `Buffer.from(s,
|
|
139
|
+
* 'base64')` silently DROPS any out-of-alphabet character and tolerates missing
|
|
140
|
+
* padding, so a malformed `content` would otherwise decode to corrupt bytes and
|
|
141
|
+
* be cached. The CLI always uploads canonical, padded standard base64
|
|
142
|
+
* (Buffer#toString('base64')), so we require exactly that: only the standard
|
|
143
|
+
* alphabet (A–Z a–z 0–9 + /), `=` padding allowed only at the end, and a length
|
|
144
|
+
* that is a multiple of 4. The empty string (an empty file) is accepted.
|
|
145
|
+
*/
|
|
146
|
+
const CANONICAL_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
147
|
+
function isCanonicalBase64(content) {
|
|
148
|
+
return content.length % 4 === 0 && CANONICAL_BASE64_RE.test(content);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Root directory holding decoded ingested sources, a sibling of the git-clone
|
|
152
|
+
* cache directory so both live under the same writable tmp space.
|
|
153
|
+
*/
|
|
154
|
+
function getIngestedCacheRoot() {
|
|
155
|
+
return path.join(path.dirname(getCacheDirectory()), 'ingested-prompts');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* PRD #647 F6 — create a fresh, unpredictable 0700 staging directory under the
|
|
159
|
+
* ingested-cache root for an in-progress upload.
|
|
160
|
+
*
|
|
161
|
+
* Decoded files are written here FIRST and only promoted into the predictable
|
|
162
|
+
* per-identifier slot once every file is on disk (see F3 atomic re-ingest).
|
|
163
|
+
* Because the name is CSPRNG-random (crypto.randomUUID via mkdtempSync) and the
|
|
164
|
+
* directory is 0700, a local attacker can't pre-plant a symlink for the
|
|
165
|
+
* writeFileSync calls to follow (TOCTOU) — the same hardening the token-bearing
|
|
166
|
+
* clone path uses in createIsolatedCloneRoot.
|
|
167
|
+
*/
|
|
168
|
+
function createIngestedStagingDir() {
|
|
169
|
+
const root = getIngestedCacheRoot();
|
|
170
|
+
fs.mkdirSync(root, { recursive: true });
|
|
171
|
+
const dir = fs.mkdtempSync(path.join(root, `staging-${crypto.randomUUID()}-`));
|
|
172
|
+
try {
|
|
173
|
+
fs.chmodSync(dir, 0o700);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* best-effort hardening (mkdtempSync already creates with 0700) */
|
|
177
|
+
}
|
|
178
|
+
return dir;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* PRD #647 F5 — enforce the LRU cap on the ingested-source registry. Evicts the
|
|
182
|
+
* least-recently-used entries (oldest `uploadedAt`) until the registry is within
|
|
183
|
+
* MAX_INGESTED_SOURCES, removing each evicted entry's on-disk directory too so
|
|
184
|
+
* memory AND disk stay bounded. An evicted identifier's next render falls into
|
|
185
|
+
* the D2 render-miss path (the registry entry is gone), instructing the caller
|
|
186
|
+
* to re-upload — it is never silently cloned.
|
|
187
|
+
*/
|
|
188
|
+
function evictIngestedIfNeeded(logger) {
|
|
189
|
+
while (ingestedSources.size > exports.MAX_INGESTED_SOURCES) {
|
|
190
|
+
let oldestKey;
|
|
191
|
+
let oldestTime = Infinity;
|
|
192
|
+
for (const [key, entry] of ingestedSources) {
|
|
193
|
+
if (entry.uploadedAt < oldestTime) {
|
|
194
|
+
oldestTime = entry.uploadedAt;
|
|
195
|
+
oldestKey = key;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (oldestKey === undefined)
|
|
199
|
+
break;
|
|
200
|
+
const evicted = ingestedSources.get(oldestKey);
|
|
201
|
+
ingestedSources.delete(oldestKey);
|
|
202
|
+
if (evicted) {
|
|
203
|
+
try {
|
|
204
|
+
fs.rmSync(evicted.localPath, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* best-effort disk cleanup; the registry entry is already gone */
|
|
208
|
+
}
|
|
209
|
+
logger.debug('Evicted ingested prompts source (LRU cap reached)', {
|
|
210
|
+
source: evicted.source,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* PRD #647 M2/M4/D5 — validate, base64-decode, harden, and cache an uploaded
|
|
217
|
+
* skill source.
|
|
218
|
+
*
|
|
219
|
+
* The decoded files are written to a fresh 0700 staging directory, then
|
|
220
|
+
* atomically promoted into the per-identifier slot (a hash of the identifier)
|
|
221
|
+
* and registered in the in-memory ingested cache so the existing render path
|
|
222
|
+
* (loadUserPrompts → loadPromptsFromDir) resolves them with no git operation.
|
|
223
|
+
* The atomic promote (F3) means a failed/invalid re-upload never destroys the
|
|
224
|
+
* prior cached entry.
|
|
225
|
+
*
|
|
226
|
+
* Hardening, all applied BEFORE the prior slot is touched so a rejected upload
|
|
227
|
+
* is never partially cached:
|
|
228
|
+
* - D3 dedup: an identical `contentHash` already cached for this identifier
|
|
229
|
+
* short-circuits with status 'unchanged' — nothing is re-decoded or rewritten.
|
|
230
|
+
* - D5 file-count cap: more than MAX_INGEST_FILES files is rejected up front.
|
|
231
|
+
* - D5 total-size cap: summed DECODED bytes over MAX_INGEST_TOTAL_BYTES is rejected.
|
|
232
|
+
* - D5 zip-slip: every file path goes through sanitizeRelativePath (reused
|
|
233
|
+
* from git-utils) to reject traversal/absolute paths.
|
|
234
|
+
* - F3 NUL byte: a `\0` in a file path is rejected with a 400 BEFORE any fs
|
|
235
|
+
* call (sanitizeRelativePath does not catch it).
|
|
236
|
+
* - D5 mode bits: each file's POSIX `mode` is sanitized (setuid/setgid/sticky
|
|
237
|
+
* stripped) via sanitizeIngestFileMode before the file is written.
|
|
238
|
+
*/
|
|
239
|
+
function ingestPromptsSource(input, logger) {
|
|
240
|
+
// Identifier (cache key) must be a non-empty string.
|
|
241
|
+
if (typeof input.source !== 'string' || input.source.trim() === '') {
|
|
242
|
+
throw new PromptsSourceValidationError('source is required and must be a non-empty string');
|
|
243
|
+
}
|
|
244
|
+
const identifier = input.source.trim();
|
|
245
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
246
|
+
// PRD #647 D3 — content-hash dedup. If the caller sent a contentHash that is
|
|
247
|
+
// already cached for this identifier, the upload is byte-for-byte unchanged:
|
|
248
|
+
// short-circuit WITHOUT re-decoding or rewriting any files and report the
|
|
249
|
+
// cached file count. A different or absent hash falls through to a normal
|
|
250
|
+
// (re)ingest below.
|
|
251
|
+
if (contentHash) {
|
|
252
|
+
const cached = ingestedSources.get(identifier);
|
|
253
|
+
if (cached && cached.contentHash === contentHash) {
|
|
254
|
+
logger.info('Ingested prompts source unchanged (dedup short-circuit)', {
|
|
255
|
+
source: cached.source,
|
|
256
|
+
fileCount: cached.fileCount,
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
source: cached.source,
|
|
260
|
+
contentHash,
|
|
261
|
+
fileCount: cached.fileCount,
|
|
262
|
+
status: 'unchanged',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Manifest must carry at least one file.
|
|
267
|
+
if (!Array.isArray(input.files) || input.files.length === 0) {
|
|
268
|
+
throw new PromptsSourceValidationError('files is required and must be a non-empty array');
|
|
269
|
+
}
|
|
270
|
+
// PRD #647 D5 — file-count cap, enforced before any decode/write.
|
|
271
|
+
if (input.files.length > MAX_INGEST_FILES) {
|
|
272
|
+
throw new PromptsSourceValidationError(`Too many files: ${input.files.length} exceeds the limit of ${MAX_INGEST_FILES}`);
|
|
273
|
+
}
|
|
274
|
+
// Decode + path-validate EVERY file (and tally decoded bytes) before writing.
|
|
275
|
+
const decoded = [];
|
|
276
|
+
let totalBytes = 0;
|
|
277
|
+
for (const raw of input.files) {
|
|
278
|
+
if (!raw || typeof raw !== 'object') {
|
|
279
|
+
throw new PromptsSourceValidationError('each file must be an object with a path and base64 content');
|
|
280
|
+
}
|
|
281
|
+
const file = raw;
|
|
282
|
+
if (typeof file.path !== 'string' || file.path.trim() === '') {
|
|
283
|
+
throw new PromptsSourceValidationError('each file must have a non-empty string path');
|
|
284
|
+
}
|
|
285
|
+
if (typeof file.content !== 'string') {
|
|
286
|
+
throw new PromptsSourceValidationError(`file content must be a base64-encoded string: ${file.path}`);
|
|
287
|
+
}
|
|
288
|
+
// PRD #647 F3 — reject a NUL byte BEFORE any fs call. git-utils'
|
|
289
|
+
// sanitizeRelativePath does not catch `\0`, so without this an embedded NUL
|
|
290
|
+
// would slip through to fs.mkdirSync/writeFileSync and throw a raw
|
|
291
|
+
// TypeError → a generic 500 plus a partial write. Map it to a clean 400
|
|
292
|
+
// here, up front, so a rejected upload never touches disk. (Backslashes are
|
|
293
|
+
// left as ordinary POSIX path characters — they cannot escape the root —
|
|
294
|
+
// keeping parity with the mock's sanitizeRelativePath.)
|
|
295
|
+
if (file.path.includes('\0')) {
|
|
296
|
+
throw new PromptsSourceValidationError(`Invalid file path "${file.path}": contains null byte`);
|
|
297
|
+
}
|
|
298
|
+
let relPath;
|
|
299
|
+
try {
|
|
300
|
+
// Reuse the folder-write traversal/zip-slip guard.
|
|
301
|
+
relPath = (0, git_utils_1.sanitizeRelativePath)(file.path);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
const message = error instanceof Error ? error.message : 'invalid path';
|
|
305
|
+
throw new PromptsSourceValidationError(`Invalid file path "${file.path}": ${message}`);
|
|
306
|
+
}
|
|
307
|
+
// PRD #647 C5 — reject malformed base64 with a 400 BEFORE decoding, so a
|
|
308
|
+
// corrupt upload is never silently decoded (Buffer.from drops bad chars)
|
|
309
|
+
// and cached. Checked after the path guards so a rejected upload never
|
|
310
|
+
// touches disk.
|
|
311
|
+
if (!isCanonicalBase64(file.content)) {
|
|
312
|
+
throw new PromptsSourceValidationError(`Invalid base64 content for file "${file.path}"`);
|
|
313
|
+
}
|
|
314
|
+
const bytes = Buffer.from(file.content, 'base64');
|
|
315
|
+
totalBytes += bytes.length;
|
|
316
|
+
// PRD #647 D5 — total decoded payload cap, checked before any write so an
|
|
317
|
+
// oversized manifest is never partially cached.
|
|
318
|
+
if (totalBytes > MAX_INGEST_TOTAL_BYTES) {
|
|
319
|
+
throw new PromptsSourceValidationError(`Total decoded payload exceeds the limit of ${MAX_INGEST_TOTAL_BYTES} bytes`);
|
|
320
|
+
}
|
|
321
|
+
decoded.push({
|
|
322
|
+
relPath,
|
|
323
|
+
bytes,
|
|
324
|
+
// PRD #647 D5 — sanitize the untrusted mode (strip setuid/setgid/sticky).
|
|
325
|
+
mode: sanitizeIngestFileMode(file.mode),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// PRD #647 F3 — atomic re-ingest. Each identifier maps to a predictable
|
|
329
|
+
// per-identifier slot keyed by its hash, but we never write INTO that slot
|
|
330
|
+
// directly: write the decoded files into a fresh, unpredictable 0700 staging
|
|
331
|
+
// directory FIRST, then promote it into place only after every file is on
|
|
332
|
+
// disk. This guarantees a write-time failure (the NUL byte rejected above,
|
|
333
|
+
// EISDIR, disk full, …) NEVER destroys the previously-cached entry — the old
|
|
334
|
+
// slot is removed only on the success path, and the in-memory registry is
|
|
335
|
+
// updated last.
|
|
336
|
+
const finalDir = path.join(getIngestedCacheRoot(), crypto.createHash('sha256').update(identifier).digest('hex'));
|
|
337
|
+
const stagingDir = createIngestedStagingDir();
|
|
338
|
+
// PRD #647 N3 — count DISTINCT written paths: two manifest entries that
|
|
339
|
+
// sanitize to the same path collapse to one file on disk, so fileCount must
|
|
340
|
+
// not double-count them.
|
|
341
|
+
const writtenPaths = new Set();
|
|
342
|
+
// PRD #647 F3 (CodeRabbit C1) — failure-ATOMIC promote. The earlier version
|
|
343
|
+
// `rmSync(finalDir)`'d the prior slot BEFORE the rename, so a rename failure
|
|
344
|
+
// destroyed the last known-good entry (the map still pointed at finalDir).
|
|
345
|
+
// Instead: move the old slot aside to an unpredictable backup, rename staging
|
|
346
|
+
// into place, then delete the backup. If the promote throws, the backup is
|
|
347
|
+
// moved back so the prior cached entry is always recoverable. rename does not
|
|
348
|
+
// follow a symlink at the source or target, so a pre-planted finalDir symlink
|
|
349
|
+
// is moved/replaced, not written through.
|
|
350
|
+
const backupDir = `${finalDir}.bak-${crypto.randomUUID()}`;
|
|
351
|
+
let hadPrevious = false;
|
|
352
|
+
try {
|
|
353
|
+
for (const { relPath, bytes, mode } of decoded) {
|
|
354
|
+
const fullPath = path.join(stagingDir, relPath);
|
|
355
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
356
|
+
fs.writeFileSync(fullPath, bytes, { mode });
|
|
357
|
+
writtenPaths.add(relPath);
|
|
358
|
+
}
|
|
359
|
+
// Move any prior slot aside FIRST (so it can be restored on failure), then
|
|
360
|
+
// promote the fully-written staging dir into place.
|
|
361
|
+
if (fs.existsSync(finalDir)) {
|
|
362
|
+
fs.renameSync(finalDir, backupDir);
|
|
363
|
+
hadPrevious = true;
|
|
364
|
+
}
|
|
365
|
+
fs.renameSync(stagingDir, finalDir);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
// Roll back the partial staging dir; never leave it behind.
|
|
369
|
+
try {
|
|
370
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* best-effort cleanup of the abandoned staging dir */
|
|
374
|
+
}
|
|
375
|
+
// Restore the prior cached entry if it was moved aside but the promote
|
|
376
|
+
// never completed, so a failed re-upload truly never destroys it.
|
|
377
|
+
if (hadPrevious && fs.existsSync(backupDir) && !fs.existsSync(finalDir)) {
|
|
378
|
+
try {
|
|
379
|
+
fs.renameSync(backupDir, finalDir);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* best-effort restore; backup remains on disk for manual recovery */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
// Promote succeeded — discard the prior good copy. Best-effort: a cleanup
|
|
388
|
+
// failure here must NOT fail an already-successful re-ingest (the new content
|
|
389
|
+
// is in place), at worst leaving a stale backup dir for the tmp sweep.
|
|
390
|
+
if (hadPrevious) {
|
|
391
|
+
try {
|
|
392
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
/* best-effort cleanup of the superseded prior copy */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const fileCount = writtenPaths.size;
|
|
399
|
+
const scrubbedSource = scrubSourceUrl(identifier);
|
|
400
|
+
// PRD #647 F5 — re-insert so a re-upload counts as most-recently-used in the
|
|
401
|
+
// access-ordered LRU, then evict if the registry now exceeds the cap.
|
|
402
|
+
ingestedSources.delete(identifier);
|
|
403
|
+
ingestedSources.set(identifier, {
|
|
404
|
+
identifier,
|
|
405
|
+
source: scrubbedSource,
|
|
406
|
+
contentHash,
|
|
407
|
+
localPath: finalDir,
|
|
408
|
+
fileCount,
|
|
409
|
+
uploadedAt: Date.now(),
|
|
410
|
+
});
|
|
411
|
+
evictIngestedIfNeeded(logger);
|
|
412
|
+
logger.info('Ingested prompts source', {
|
|
413
|
+
source: scrubbedSource,
|
|
414
|
+
fileCount,
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
source: scrubbedSource,
|
|
418
|
+
contentHash,
|
|
419
|
+
fileCount,
|
|
420
|
+
status: 'ingested',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Load prompts from a previously-ingested source (PRD #647 M3 + D2).
|
|
425
|
+
*
|
|
426
|
+
* Resolves the identifier from the in-memory ingested cache and reads the
|
|
427
|
+
* decoded upload directly — NO git operation. A miss (evicted/never-uploaded)
|
|
428
|
+
* throws IngestedSourceNotFoundError with re-upload guidance rather than
|
|
429
|
+
* silently falling back to a clone (D2: ingested identifiers are never cloned).
|
|
430
|
+
* The thrown message deliberately avoids the generic "Prompt not found" wording
|
|
431
|
+
* AND any git/clone/scheme vocabulary so the caller learns to re-upload and the
|
|
432
|
+
* "no clone attempted" guarantee is observable.
|
|
433
|
+
*
|
|
434
|
+
* Note the distinction the contract requires: a MISSING identifier (no cache
|
|
435
|
+
* entry) yields this guidance, whereas a CACHED identifier that simply does not
|
|
436
|
+
* contain the requested skill name returns the loaded prompts and lets the
|
|
437
|
+
* caller surface the normal "Prompt not found".
|
|
438
|
+
*/
|
|
439
|
+
function loadIngestedPrompts(identifier, logger) {
|
|
440
|
+
const entry = ingestedSources.get(identifier);
|
|
441
|
+
if (!entry || !fs.existsSync(entry.localPath)) {
|
|
442
|
+
const scrubbed = scrubSourceUrl(identifier);
|
|
443
|
+
logger.warn('Ingested prompts source not found; (re)upload required', {
|
|
444
|
+
source: scrubbed,
|
|
445
|
+
});
|
|
446
|
+
throw new IngestedSourceNotFoundError(`Ingested source not found: ${scrubbed}. (Re)upload it via POST /api/v1/prompts/sources before rendering.`);
|
|
447
|
+
}
|
|
448
|
+
// PRD #647 F5 — mark this entry most-recently-used so a frequently-rendered
|
|
449
|
+
// source survives the LRU eviction in evictIngestedIfNeeded.
|
|
450
|
+
entry.uploadedAt = Date.now();
|
|
451
|
+
const prompts = loadPromptsFromDir(entry.localPath, logger);
|
|
452
|
+
logger.info('Loaded user prompts from ingested source', {
|
|
453
|
+
total: prompts.length,
|
|
454
|
+
source: entry.source,
|
|
455
|
+
});
|
|
456
|
+
return prompts;
|
|
457
|
+
}
|
|
66
458
|
/**
|
|
67
459
|
* Read user prompts configuration from environment variables
|
|
68
460
|
* Returns null if DOT_AI_USER_PROMPTS_REPO is not set
|
|
@@ -525,14 +917,112 @@ function loadSkillFolder(dirPath, dirName, logger) {
|
|
|
525
917
|
}
|
|
526
918
|
return prompt;
|
|
527
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* Read flat `.md` prompt files and skill folders (directories with SKILL.md)
|
|
922
|
+
* from a prompts directory into Prompt objects.
|
|
923
|
+
*
|
|
924
|
+
* Shared by the git-clone loader path and the PRD #647 ingested (uploaded)
|
|
925
|
+
* source path so both resolve through ONE identical loader — the only
|
|
926
|
+
* difference between a `?repo=` clone and an uploaded `?source=` is how the
|
|
927
|
+
* directory was populated. The caller is responsible for ensuring the directory
|
|
928
|
+
* exists.
|
|
929
|
+
*/
|
|
930
|
+
function loadPromptsFromDir(promptsDir, logger) {
|
|
931
|
+
const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
|
|
932
|
+
const prompts = [];
|
|
933
|
+
const loadedNames = new Set();
|
|
934
|
+
// 1. Load flat .md files (existing behavior)
|
|
935
|
+
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
936
|
+
for (const entry of mdFiles) {
|
|
937
|
+
try {
|
|
938
|
+
const filePath = path.join(promptsDir, entry.name);
|
|
939
|
+
const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
|
|
940
|
+
prompts.push(prompt);
|
|
941
|
+
loadedNames.add(prompt.name);
|
|
942
|
+
logger.debug('Loaded user prompt', {
|
|
943
|
+
name: prompt.name,
|
|
944
|
+
file: entry.name,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
949
|
+
logger.warn('Failed to load user prompt file, skipping', {
|
|
950
|
+
file: entry.name,
|
|
951
|
+
error: errorMessage,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
// 2. Load skill folders (directories containing SKILL.md)
|
|
956
|
+
const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
|
|
957
|
+
for (const dir of directories) {
|
|
958
|
+
try {
|
|
959
|
+
const dirPath = path.join(promptsDir, dir.name);
|
|
960
|
+
const prompt = loadSkillFolder(dirPath, dir.name, logger);
|
|
961
|
+
if (prompt) {
|
|
962
|
+
if (loadedNames.has(prompt.name)) {
|
|
963
|
+
logger.warn('Skill folder name collision with existing prompt, skipping', {
|
|
964
|
+
name: prompt.name,
|
|
965
|
+
dir: dir.name,
|
|
966
|
+
});
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
prompts.push(prompt);
|
|
970
|
+
loadedNames.add(prompt.name);
|
|
971
|
+
logger.debug('Loaded user skill folder', {
|
|
972
|
+
name: prompt.name,
|
|
973
|
+
dir: dir.name,
|
|
974
|
+
filesCount: prompt.files?.length ?? 0,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
980
|
+
logger.warn('Failed to load skill folder, skipping', {
|
|
981
|
+
dir: dir.name,
|
|
982
|
+
error: errorMessage,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return prompts;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Raised when a PER-REQUEST prompts-repo override (PRD #581/#621) fails to load —
|
|
990
|
+
* e.g. the clone is rejected (missing/wrong forwarded credential) or the source is
|
|
991
|
+
* unreachable. An env-var-configured repo failure falls back to built-in prompts,
|
|
992
|
+
* but an override is an explicit caller request, so its failure must surface as an
|
|
993
|
+
* error instead of silently returning fewer skills ("fail open" — issue #575).
|
|
994
|
+
*
|
|
995
|
+
* The `message` is already credential-scrubbed by the thrower, so callers may
|
|
996
|
+
* surface it to clients directly.
|
|
997
|
+
*/
|
|
998
|
+
class UserPromptsOverrideError extends Error {
|
|
999
|
+
constructor(message) {
|
|
1000
|
+
super(message);
|
|
1001
|
+
this.name = 'UserPromptsOverrideError';
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
exports.UserPromptsOverrideError = UserPromptsOverrideError;
|
|
528
1005
|
/**
|
|
529
1006
|
* Load user prompts from a git repository.
|
|
530
1007
|
*
|
|
531
1008
|
* When `override` is supplied, fetches from that repository for this call only,
|
|
532
1009
|
* ignoring DOT_AI_USER_PROMPTS_REPO env vars. Otherwise, uses env-var configuration.
|
|
533
|
-
*
|
|
1010
|
+
*
|
|
1011
|
+
* Returns an empty array when not configured, or when an ENV-VAR-configured repo
|
|
1012
|
+
* fails to load (graceful fallback to built-in prompts). When a per-request
|
|
1013
|
+
* `override` fails to load, throws {@link UserPromptsOverrideError} instead — the
|
|
1014
|
+
* caller asked for that specific source, so the failure must not be swallowed.
|
|
534
1015
|
*/
|
|
535
1016
|
async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
1017
|
+
// PRD #647 D1/M3: an ingested-source override resolves from the in-memory
|
|
1018
|
+
// uploaded-source cache and is NEVER cloned. Short-circuit BEFORE
|
|
1019
|
+
// getUserPromptsConfigFromOverride, which would reject a non-http identifier
|
|
1020
|
+
// such as `local:<label>`. The render handler only sets this when the request
|
|
1021
|
+
// carries an explicit `?source=` signal, so the env-var and `?repo=` clone
|
|
1022
|
+
// paths below are untouched.
|
|
1023
|
+
if (override?.ingestedSource) {
|
|
1024
|
+
return loadIngestedPrompts(override.ingestedSource, logger);
|
|
1025
|
+
}
|
|
536
1026
|
let config;
|
|
537
1027
|
try {
|
|
538
1028
|
// Override validation can throw on bad scheme / traversal / branch — keep
|
|
@@ -545,6 +1035,12 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
545
1035
|
catch (error) {
|
|
546
1036
|
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
547
1037
|
const safeMessage = (0, git_utils_1.scrubCredentials)(rawMessage);
|
|
1038
|
+
// A per-request override that fails validation is an explicit caller request
|
|
1039
|
+
// gone wrong — surface it (issue #575). Env-var config failures fall back.
|
|
1040
|
+
if (override) {
|
|
1041
|
+
logger.error('Per-request prompts override is invalid', new Error(safeMessage));
|
|
1042
|
+
throw new UserPromptsOverrideError(safeMessage);
|
|
1043
|
+
}
|
|
548
1044
|
logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
|
|
549
1045
|
return [];
|
|
550
1046
|
}
|
|
@@ -569,62 +1065,8 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
569
1065
|
});
|
|
570
1066
|
return [];
|
|
571
1067
|
}
|
|
572
|
-
// Load flat .md files and skill folders from the prompts directory
|
|
573
|
-
const
|
|
574
|
-
const prompts = [];
|
|
575
|
-
const loadedNames = new Set();
|
|
576
|
-
// 1. Load flat .md files (existing behavior)
|
|
577
|
-
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
578
|
-
for (const entry of mdFiles) {
|
|
579
|
-
try {
|
|
580
|
-
const filePath = path.join(promptsDir, entry.name);
|
|
581
|
-
const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
|
|
582
|
-
prompts.push(prompt);
|
|
583
|
-
loadedNames.add(prompt.name);
|
|
584
|
-
logger.debug('Loaded user prompt', {
|
|
585
|
-
name: prompt.name,
|
|
586
|
-
file: entry.name,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
catch (error) {
|
|
590
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
591
|
-
logger.warn('Failed to load user prompt file, skipping', {
|
|
592
|
-
file: entry.name,
|
|
593
|
-
error: errorMessage,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
// 2. Load skill folders (directories containing SKILL.md)
|
|
598
|
-
const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
|
|
599
|
-
for (const dir of directories) {
|
|
600
|
-
try {
|
|
601
|
-
const dirPath = path.join(promptsDir, dir.name);
|
|
602
|
-
const prompt = loadSkillFolder(dirPath, dir.name, logger);
|
|
603
|
-
if (prompt) {
|
|
604
|
-
if (loadedNames.has(prompt.name)) {
|
|
605
|
-
logger.warn('Skill folder name collision with existing prompt, skipping', {
|
|
606
|
-
name: prompt.name,
|
|
607
|
-
dir: dir.name,
|
|
608
|
-
});
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
prompts.push(prompt);
|
|
612
|
-
loadedNames.add(prompt.name);
|
|
613
|
-
logger.debug('Loaded user skill folder', {
|
|
614
|
-
name: prompt.name,
|
|
615
|
-
dir: dir.name,
|
|
616
|
-
filesCount: prompt.files?.length ?? 0,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
catch (error) {
|
|
621
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
622
|
-
logger.warn('Failed to load skill folder, skipping', {
|
|
623
|
-
dir: dir.name,
|
|
624
|
-
error: errorMessage,
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
}
|
|
1068
|
+
// Load flat .md files and skill folders from the prompts directory.
|
|
1069
|
+
const prompts = loadPromptsFromDir(promptsDir, logger);
|
|
628
1070
|
logger.info('Loaded user prompts from repository', {
|
|
629
1071
|
total: prompts.length,
|
|
630
1072
|
url: sanitizeUrlForLogging(config.repoUrl),
|
|
@@ -638,6 +1080,14 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
638
1080
|
const safeMessage = (0, git_utils_1.scrubCredentials)(config.gitToken
|
|
639
1081
|
? rawMessage.replaceAll(config.gitToken, '***')
|
|
640
1082
|
: rawMessage);
|
|
1083
|
+
// A per-request override clone failure (bad/missing forwarded credential,
|
|
1084
|
+
// unreachable host, missing branch/subdir) must NOT silently fall back to
|
|
1085
|
+
// built-in prompts — the caller explicitly requested this source, so surface
|
|
1086
|
+
// the failure (issue #575). Env-var-configured repo failures still fall back.
|
|
1087
|
+
if (override) {
|
|
1088
|
+
logger.error('Failed to load per-request prompts override', new Error(safeMessage));
|
|
1089
|
+
throw new UserPromptsOverrideError(safeMessage);
|
|
1090
|
+
}
|
|
641
1091
|
logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
|
|
642
1092
|
return [];
|
|
643
1093
|
}
|
|
@@ -667,6 +1117,21 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
667
1117
|
function clearUserPromptsCache() {
|
|
668
1118
|
cacheState = null;
|
|
669
1119
|
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Clear the ingested-source cache (PRD #647, useful for testing).
|
|
1122
|
+
* Only drops the in-memory registry; on-disk directories are left to be
|
|
1123
|
+
* overwritten by the next upload or cleaned with the tmp directory.
|
|
1124
|
+
*/
|
|
1125
|
+
function clearIngestedPromptsSources() {
|
|
1126
|
+
ingestedSources.clear();
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Inspect the ingested-source cache (PRD #647, for testing/debugging).
|
|
1130
|
+
* Returns the scrubbed identifiers currently cached.
|
|
1131
|
+
*/
|
|
1132
|
+
function getIngestedPromptsSources() {
|
|
1133
|
+
return [...ingestedSources.values()].map(entry => entry.source);
|
|
1134
|
+
}
|
|
670
1135
|
/**
|
|
671
1136
|
* Get current cache state (for testing/debugging)
|
|
672
1137
|
*/
|
package/dist/interfaces/mcp.d.ts
CHANGED
|
@@ -73,7 +73,29 @@ export declare class MCPServer {
|
|
|
73
73
|
private generateRequestId;
|
|
74
74
|
start(): Promise<void>;
|
|
75
75
|
private startHttpTransport;
|
|
76
|
+
/**
|
|
77
|
+
* Buffer and JSON-parse the request body.
|
|
78
|
+
*
|
|
79
|
+
* PRD #647 F1: when `maxBytes` is supplied (the untrusted ingest route), the
|
|
80
|
+
* body is rejected with RequestBodyTooLargeError as soon as the declared
|
|
81
|
+
* Content-Length OR the accumulated bytes exceed the ceiling — so an
|
|
82
|
+
* authenticated multi-GB POST can't buffer unbounded and OOM the shared
|
|
83
|
+
* process. When `maxBytes` is omitted (every other endpoint), behavior is
|
|
84
|
+
* exactly as before: no cap.
|
|
85
|
+
*/
|
|
76
86
|
private parseRequestBody;
|
|
87
|
+
/**
|
|
88
|
+
* PRD #647 F1: true only for the prompts source ingest endpoint, the one
|
|
89
|
+
* untrusted route the raw-body cap is scoped to. Parses the pathname so a
|
|
90
|
+
* query string can't bypass (or wrongly trip) the cap.
|
|
91
|
+
*
|
|
92
|
+
* PRD #647 C2 (CodeRabbit): the cap check runs BEFORE route dispatch, so a
|
|
93
|
+
* non-canonical pathname must be normalized here or it slips past the cap and
|
|
94
|
+
* buffers an uncapped body (DoS). A strict `===` let `POST /api/v1/prompts/
|
|
95
|
+
* sources/` (trailing slash) — the same ingest surface — skip `maxBytes`.
|
|
96
|
+
* Collapse trailing slashes (keeping a bare "/" intact) before comparing.
|
|
97
|
+
*/
|
|
98
|
+
private isPromptsIngestRequest;
|
|
77
99
|
stop(): Promise<void>;
|
|
78
100
|
isReady(): boolean;
|
|
79
101
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/interfaces/mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAgFtC,OAAO,EAAgB,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAcvD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;
|
|
1
|
+
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/interfaces/mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAgFtC,OAAO,EAAgB,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAcvD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AAqDD,qBAAa,SAAS;IACpB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,UAAU,CAAC,CAAkC;IACrD,4EAA4E;IAC5E,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,cAAc,CAAC,CAAiC;IACxD,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,CAA6B;IAC9C,OAAO,CAAC,aAAa,CAAC,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAC,CAAM;gBAEZ,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe;IA6BjD;;;OAGG;IACH,gBAAgB,IAAI,aAAa,GAAG,SAAS;IAQ7C;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAuBxB;;OAEG;IACH,OAAO,CAAC,eAAe;IA2CvB;;OAEG;IACH,OAAO,CAAC,WAAW;IA8KnB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;OAIG;YACW,mBAAmB;IA6CjC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,qBAAqB;IAS7B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,iBAAiB;IAInB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAed,kBAAkB;IA0ThC;;;;;;;;;OASG;YACW,gBAAgB;IAgD9B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,sBAAsB;IAWxB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoC3B,OAAO,IAAI,OAAO;CAGnB"}
|