@vfarcic/dot-ai 1.21.1 → 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/git-utils.d.ts +46 -0
- package/dist/core/git-utils.d.ts.map +1 -1
- package/dist/core/git-utils.js +192 -1
- package/dist/core/user-prompts-loader.d.ts +129 -1
- package/dist/core/user-prompts-loader.d.ts.map +1 -1
- package/dist/core/user-prompts-loader.js +654 -68
- package/dist/interfaces/cors-headers.d.ts +41 -0
- package/dist/interfaces/cors-headers.d.ts.map +1 -0
- package/dist/interfaces/cors-headers.js +43 -0
- package/dist/interfaces/header-redaction.d.ts +28 -0
- package/dist/interfaces/header-redaction.d.ts.map +1 -0
- package/dist/interfaces/header-redaction.js +48 -0
- package/dist/interfaces/mcp.d.ts +22 -0
- package/dist/interfaces/mcp.d.ts.map +1 -1
- package/dist/interfaces/mcp.js +131 -7
- 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 +59 -15
- package/dist/interfaces/rest-api.d.ts.map +1 -1
- package/dist/interfaces/rest-api.js +288 -52
- 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,14 +57,404 @@ 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"));
|
|
60
65
|
const os = __importStar(require("os"));
|
|
66
|
+
const crypto = __importStar(require("crypto"));
|
|
61
67
|
const git_utils_1 = require("./git-utils");
|
|
62
68
|
const prompts_1 = require("../tools/prompts");
|
|
63
69
|
// In-memory cache state (persists across requests within same process)
|
|
64
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
|
+
}
|
|
65
458
|
/**
|
|
66
459
|
* Read user prompts configuration from environment variables
|
|
67
460
|
* Returns null if DOT_AI_USER_PROMPTS_REPO is not set
|
|
@@ -160,7 +553,10 @@ function getUserPromptsConfigFromOverride(override) {
|
|
|
160
553
|
repoUrl: override.repoUrl,
|
|
161
554
|
branch,
|
|
162
555
|
subPath: normalizedSubPath,
|
|
163
|
-
|
|
556
|
+
// PRD #621 M2 / Decision 4: a request-supplied token (override.gitToken)
|
|
557
|
+
// takes precedence over the server env credential for this request only;
|
|
558
|
+
// the env credential remains the fallback when no header is present.
|
|
559
|
+
gitToken: override.gitToken ?? process.env.DOT_AI_GIT_TOKEN,
|
|
164
560
|
cacheTtlSeconds,
|
|
165
561
|
};
|
|
166
562
|
}
|
|
@@ -254,9 +650,36 @@ function isValidGitBranch(branch) {
|
|
|
254
650
|
return /^[a-zA-Z0-9_.\-/]+$/.test(branch);
|
|
255
651
|
}
|
|
256
652
|
/**
|
|
257
|
-
*
|
|
653
|
+
* Create a unique, throwaway ROOT directory for a token-bearing override
|
|
654
|
+
* request (PRD #621 M3 / Decision 2). It lives alongside the shared cache
|
|
655
|
+
* directory but is NOT the shared slot, so an authenticated private clone is
|
|
656
|
+
* never written to (or served from) the unauthenticated cache. The caller
|
|
657
|
+
* removes it after reading.
|
|
658
|
+
*
|
|
659
|
+
* Hardening (LOW-4): created atomically via fs.mkdtempSync with a CSPRNG
|
|
660
|
+
* (crypto.randomUUID) name component and mode 0700, so the authenticated clone
|
|
661
|
+
* cannot land in a predictable, world-readable location.
|
|
662
|
+
*/
|
|
663
|
+
function createIsolatedCloneRoot() {
|
|
664
|
+
const parent = path.dirname(getCacheDirectory());
|
|
665
|
+
const root = fs.mkdtempSync(path.join(parent, `user-prompts-override-${crypto.randomUUID()}-`));
|
|
666
|
+
try {
|
|
667
|
+
fs.chmodSync(root, 0o700);
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
/* best-effort hardening (mkdtempSync already creates with 0700) */
|
|
671
|
+
}
|
|
672
|
+
return root;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Clone the user prompts repository.
|
|
676
|
+
*
|
|
677
|
+
* `overrideToken` (PRD #621 M3) is a per-request credential that, when present,
|
|
678
|
+
* overrides env auth for this clone only and is scoped to the source host with
|
|
679
|
+
* no cross-host redirect forwarding (handled in cloneRepo). When omitted, the
|
|
680
|
+
* clone authenticates via env exactly as before.
|
|
258
681
|
*/
|
|
259
|
-
async function cloneRepository(config, localPath, logger) {
|
|
682
|
+
async function cloneRepository(config, localPath, logger, overrideToken) {
|
|
260
683
|
// Validate branch name as defense-in-depth
|
|
261
684
|
if (!isValidGitBranch(config.branch)) {
|
|
262
685
|
throw new Error(`Invalid branch name: ${config.branch}`);
|
|
@@ -280,6 +703,9 @@ async function cloneRepository(config, localPath, logger) {
|
|
|
280
703
|
await (0, git_utils_1.cloneRepo)(config.repoUrl, localPath, {
|
|
281
704
|
branch: config.branch,
|
|
282
705
|
depth: 1,
|
|
706
|
+
// Per-request override credential (PRD #621 M3). undefined → cloneRepo
|
|
707
|
+
// falls back to env auth, i.e. today's behavior unchanged.
|
|
708
|
+
token: overrideToken,
|
|
283
709
|
});
|
|
284
710
|
logger.info('Successfully cloned user prompts repository', {
|
|
285
711
|
url: sanitizedUrl,
|
|
@@ -287,14 +713,24 @@ async function cloneRepository(config, localPath, logger) {
|
|
|
287
713
|
});
|
|
288
714
|
}
|
|
289
715
|
catch (error) {
|
|
716
|
+
const scrub = (raw) => (0, git_utils_1.scrubCredentials)(config.gitToken ? raw.replaceAll(config.gitToken, '***') : raw);
|
|
290
717
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
291
|
-
const sanitizedError = (
|
|
292
|
-
? errorMessage.replaceAll(config.gitToken, '***')
|
|
293
|
-
: errorMessage);
|
|
718
|
+
const sanitizedError = scrub(errorMessage);
|
|
294
719
|
logger.error('Failed to clone user prompts repository', new Error(sanitizedError), {
|
|
295
720
|
url: sanitizedUrl,
|
|
296
721
|
branch: config.branch,
|
|
297
722
|
});
|
|
723
|
+
// LOW-5: scrub the caught error IN PLACE (message + stack) before attaching
|
|
724
|
+
// it as `cause`, so a serialized `.cause` cannot leak the token. (With the
|
|
725
|
+
// GIT_ASKPASS rework the override token is no longer on the git argv/URL, so
|
|
726
|
+
// it cannot appear here in the first place — this is defense-in-depth, esp.
|
|
727
|
+
// for the env-credential path which still embeds its token in the URL.)
|
|
728
|
+
if (error instanceof Error) {
|
|
729
|
+
error.message = scrub(error.message);
|
|
730
|
+
if (error.stack) {
|
|
731
|
+
error.stack = scrub(error.stack);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
298
734
|
throw new Error(`Failed to clone user prompts repository: ${sanitizedError}`, { cause: error });
|
|
299
735
|
}
|
|
300
736
|
}
|
|
@@ -326,10 +762,56 @@ async function pullRepository(config, localPath, logger) {
|
|
|
326
762
|
}
|
|
327
763
|
}
|
|
328
764
|
/**
|
|
329
|
-
* Ensure the repository is cloned and up-to-date
|
|
330
|
-
*
|
|
765
|
+
* Ensure the repository is cloned and up-to-date.
|
|
766
|
+
*
|
|
767
|
+
* Returns the path to the prompts directory within the repository, plus an
|
|
768
|
+
* optional `isolatedRoot` the caller must remove after reading (set only for
|
|
769
|
+
* the token-bearing isolation path below).
|
|
770
|
+
*
|
|
771
|
+
* PRD #621 M3 / Decision 2 (cache isolation): when `overrideToken` is present
|
|
772
|
+
* (a request forwarded an X-Dot-AI-Git-Token), the clone is performed into a
|
|
773
|
+
* unique throwaway directory and the shared `cacheState` is neither read nor
|
|
774
|
+
* written. This guarantees an authenticated private clone is never served from
|
|
775
|
+
* — nor written into — the shared unauthenticated cache slot for the same
|
|
776
|
+
* (repoUrl, branch, subPath) coordinate, and that the token never enters the
|
|
777
|
+
* cache key. Token-less requests use the shared cache exactly as before.
|
|
331
778
|
*/
|
|
332
|
-
async function ensureRepository(config, logger, forceRefresh = false) {
|
|
779
|
+
async function ensureRepository(config, logger, forceRefresh = false, overrideToken) {
|
|
780
|
+
if (overrideToken) {
|
|
781
|
+
const isolatedRoot = createIsolatedCloneRoot();
|
|
782
|
+
// Clone into a subdirectory of the 0700 root so the root's restrictive
|
|
783
|
+
// permissions cover the authenticated clone (git creates `cloneDir` itself).
|
|
784
|
+
const cloneDir = path.join(isolatedRoot, 'repo');
|
|
785
|
+
logger.debug('Token-bearing override: cloning in isolation', {
|
|
786
|
+
url: sanitizeUrlForLogging(config.repoUrl),
|
|
787
|
+
branch: config.branch,
|
|
788
|
+
});
|
|
789
|
+
try {
|
|
790
|
+
await cloneRepository(config, cloneDir, logger, overrideToken);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
// Remove any partial clone before propagating so a failed authenticated
|
|
794
|
+
// request leaves no isolated directory behind. With GIT_ASKPASS the token
|
|
795
|
+
// is never written to disk, so a cleanup failure cannot leave a PAT
|
|
796
|
+
// behind — but warn (don't swallow) for observability.
|
|
797
|
+
try {
|
|
798
|
+
fs.rmSync(isolatedRoot, { recursive: true, force: true });
|
|
799
|
+
}
|
|
800
|
+
catch (cleanupError) {
|
|
801
|
+
logger.warn('Failed to remove isolated clone directory after clone failure', {
|
|
802
|
+
path: isolatedRoot,
|
|
803
|
+
error: cleanupError instanceof Error
|
|
804
|
+
? cleanupError.message
|
|
805
|
+
: String(cleanupError),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
const promptsDir = config.subPath
|
|
811
|
+
? path.join(cloneDir, config.subPath)
|
|
812
|
+
: cloneDir;
|
|
813
|
+
return { promptsDir, isolatedRoot };
|
|
814
|
+
}
|
|
333
815
|
const localPath = getCacheDirectory();
|
|
334
816
|
const now = Date.now();
|
|
335
817
|
const ttlMs = config.cacheTtlSeconds * 1000;
|
|
@@ -375,7 +857,11 @@ async function ensureRepository(config, logger, forceRefresh = false) {
|
|
|
375
857
|
});
|
|
376
858
|
}
|
|
377
859
|
// Return path to prompts directory (with optional subPath)
|
|
378
|
-
return
|
|
860
|
+
return {
|
|
861
|
+
promptsDir: config.subPath
|
|
862
|
+
? path.join(localPath, config.subPath)
|
|
863
|
+
: localPath,
|
|
864
|
+
};
|
|
379
865
|
}
|
|
380
866
|
const SKILL_FILE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB per file (before base64 encoding)
|
|
381
867
|
const SKILL_FILENAME = 'SKILL.md';
|
|
@@ -431,14 +917,112 @@ function loadSkillFolder(dirPath, dirName, logger) {
|
|
|
431
917
|
}
|
|
432
918
|
return prompt;
|
|
433
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;
|
|
434
1005
|
/**
|
|
435
1006
|
* Load user prompts from a git repository.
|
|
436
1007
|
*
|
|
437
1008
|
* When `override` is supplied, fetches from that repository for this call only,
|
|
438
1009
|
* ignoring DOT_AI_USER_PROMPTS_REPO env vars. Otherwise, uses env-var configuration.
|
|
439
|
-
*
|
|
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.
|
|
440
1015
|
*/
|
|
441
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
|
+
}
|
|
442
1026
|
let config;
|
|
443
1027
|
try {
|
|
444
1028
|
// Override validation can throw on bad scheme / traversal / branch — keep
|
|
@@ -451,6 +1035,12 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
451
1035
|
catch (error) {
|
|
452
1036
|
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
453
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
|
+
}
|
|
454
1044
|
logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
|
|
455
1045
|
return [];
|
|
456
1046
|
}
|
|
@@ -458,8 +1048,16 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
458
1048
|
logger.debug('User prompts not configured (DOT_AI_USER_PROMPTS_REPO not set)');
|
|
459
1049
|
return [];
|
|
460
1050
|
}
|
|
1051
|
+
// PRD #621 M3 / Decision 2: a request-forwarded credential (override.gitToken)
|
|
1052
|
+
// triggers per-request cache isolation. Track the throwaway clone directory so
|
|
1053
|
+
// it is removed after the read (success-path cleanup; the failure path is
|
|
1054
|
+
// cleaned up inside ensureRepository).
|
|
1055
|
+
const overrideToken = override?.gitToken;
|
|
1056
|
+
let isolatedRoot;
|
|
461
1057
|
try {
|
|
462
|
-
const
|
|
1058
|
+
const ensured = await ensureRepository(config, logger, forceRefresh, overrideToken);
|
|
1059
|
+
const promptsDir = ensured.promptsDir;
|
|
1060
|
+
isolatedRoot = ensured.isolatedRoot;
|
|
463
1061
|
if (!fs.existsSync(promptsDir)) {
|
|
464
1062
|
logger.warn('User prompts directory not found in repository', {
|
|
465
1063
|
path: promptsDir,
|
|
@@ -467,62 +1065,8 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
467
1065
|
});
|
|
468
1066
|
return [];
|
|
469
1067
|
}
|
|
470
|
-
// Load flat .md files and skill folders from the prompts directory
|
|
471
|
-
const
|
|
472
|
-
const prompts = [];
|
|
473
|
-
const loadedNames = new Set();
|
|
474
|
-
// 1. Load flat .md files (existing behavior)
|
|
475
|
-
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
476
|
-
for (const entry of mdFiles) {
|
|
477
|
-
try {
|
|
478
|
-
const filePath = path.join(promptsDir, entry.name);
|
|
479
|
-
const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
|
|
480
|
-
prompts.push(prompt);
|
|
481
|
-
loadedNames.add(prompt.name);
|
|
482
|
-
logger.debug('Loaded user prompt', {
|
|
483
|
-
name: prompt.name,
|
|
484
|
-
file: entry.name,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
catch (error) {
|
|
488
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
489
|
-
logger.warn('Failed to load user prompt file, skipping', {
|
|
490
|
-
file: entry.name,
|
|
491
|
-
error: errorMessage,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
// 2. Load skill folders (directories containing SKILL.md)
|
|
496
|
-
const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
|
|
497
|
-
for (const dir of directories) {
|
|
498
|
-
try {
|
|
499
|
-
const dirPath = path.join(promptsDir, dir.name);
|
|
500
|
-
const prompt = loadSkillFolder(dirPath, dir.name, logger);
|
|
501
|
-
if (prompt) {
|
|
502
|
-
if (loadedNames.has(prompt.name)) {
|
|
503
|
-
logger.warn('Skill folder name collision with existing prompt, skipping', {
|
|
504
|
-
name: prompt.name,
|
|
505
|
-
dir: dir.name,
|
|
506
|
-
});
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
prompts.push(prompt);
|
|
510
|
-
loadedNames.add(prompt.name);
|
|
511
|
-
logger.debug('Loaded user skill folder', {
|
|
512
|
-
name: prompt.name,
|
|
513
|
-
dir: dir.name,
|
|
514
|
-
filesCount: prompt.files?.length ?? 0,
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
catch (error) {
|
|
519
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
520
|
-
logger.warn('Failed to load skill folder, skipping', {
|
|
521
|
-
dir: dir.name,
|
|
522
|
-
error: errorMessage,
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
}
|
|
1068
|
+
// Load flat .md files and skill folders from the prompts directory.
|
|
1069
|
+
const prompts = loadPromptsFromDir(promptsDir, logger);
|
|
526
1070
|
logger.info('Loaded user prompts from repository', {
|
|
527
1071
|
total: prompts.length,
|
|
528
1072
|
url: sanitizeUrlForLogging(config.repoUrl),
|
|
@@ -536,9 +1080,36 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
536
1080
|
const safeMessage = (0, git_utils_1.scrubCredentials)(config.gitToken
|
|
537
1081
|
? rawMessage.replaceAll(config.gitToken, '***')
|
|
538
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
|
+
}
|
|
539
1091
|
logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
|
|
540
1092
|
return [];
|
|
541
1093
|
}
|
|
1094
|
+
finally {
|
|
1095
|
+
// PRD #621 M3 / Decision 2: remove the per-request isolated clone (if any)
|
|
1096
|
+
// so token-bearing override clones leave no on-disk residue. With
|
|
1097
|
+
// GIT_ASKPASS the token is never written to disk, so a failed cleanup
|
|
1098
|
+
// cannot leave a PAT behind — but warn (don't swallow) for observability.
|
|
1099
|
+
if (isolatedRoot) {
|
|
1100
|
+
try {
|
|
1101
|
+
fs.rmSync(isolatedRoot, { recursive: true, force: true });
|
|
1102
|
+
}
|
|
1103
|
+
catch (cleanupError) {
|
|
1104
|
+
logger.warn('Failed to remove isolated clone directory', {
|
|
1105
|
+
path: isolatedRoot,
|
|
1106
|
+
error: cleanupError instanceof Error
|
|
1107
|
+
? cleanupError.message
|
|
1108
|
+
: String(cleanupError),
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
542
1113
|
}
|
|
543
1114
|
/**
|
|
544
1115
|
* Clear the cache state (useful for testing)
|
|
@@ -546,6 +1117,21 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
546
1117
|
function clearUserPromptsCache() {
|
|
547
1118
|
cacheState = null;
|
|
548
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
|
+
}
|
|
549
1135
|
/**
|
|
550
1136
|
* Get current cache state (for testing/debugging)
|
|
551
1137
|
*/
|