@vfarcic/dot-ai 1.21.1 → 1.22.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 +9 -0
- package/dist/core/user-prompts-loader.d.ts.map +1 -1
- package/dist/core/user-prompts-loader.js +132 -11
- 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.map +1 -1
- package/dist/interfaces/mcp.js +13 -3
- package/dist/interfaces/rest-api.d.ts +42 -12
- package/dist/interfaces/rest-api.d.ts.map +1 -1
- package/dist/interfaces/rest-api.js +153 -46
- package/package.json +1 -1
package/dist/core/git-utils.d.ts
CHANGED
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
* - GITHUB_APP_ENABLED: Enable GitHub App authentication
|
|
12
12
|
* - GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID: GitHub App config
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Environment variable name through which a per-request override credential
|
|
16
|
+
* (PRD #621 M3) is handed to the GIT_ASKPASS helper. The token travels in the
|
|
17
|
+
* git child process's ENVIRONMENT — never on its argv (ps/proc) and never
|
|
18
|
+
* embedded in the clone URL written to `.git/config`.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ASKPASS_TOKEN_ENV = "DOT_AI_GIT_ASKPASS_TOKEN";
|
|
21
|
+
/**
|
|
22
|
+
* Environment variable naming the host the override token is bound to. The
|
|
23
|
+
* GIT_ASKPASS helper emits the token ONLY when git's credential prompt names
|
|
24
|
+
* this host, so a cross-host HTTP redirect can never obtain it (Decision 3).
|
|
25
|
+
*/
|
|
26
|
+
export declare const ASKPASS_HOST_ENV = "DOT_AI_GIT_ASKPASS_HOST";
|
|
14
27
|
export interface GitAuthConfig {
|
|
15
28
|
pat?: string;
|
|
16
29
|
githubApp?: {
|
|
@@ -31,7 +44,40 @@ export declare function sanitizeRelativePath(relativePath: string): string;
|
|
|
31
44
|
export interface CloneOptions {
|
|
32
45
|
branch?: string;
|
|
33
46
|
depth?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Per-call git credential (PRD #621 M3). When supplied it OVERRIDES the
|
|
49
|
+
* env/GitHub-App auth (`getGitAuthConfigFromEnv`) for this clone only
|
|
50
|
+
* (Decision 4) and is scoped to the host in `repoUrl` with cross-host
|
|
51
|
+
* redirect forwarding disabled (Decision 3 — see buildOverrideCloneAuth).
|
|
52
|
+
* When omitted, the clone uses env auth exactly as before.
|
|
53
|
+
*/
|
|
54
|
+
token?: string;
|
|
34
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* PRD #621 M3 / Decision 3: build the clone URL + intended host for a
|
|
58
|
+
* per-request override credential.
|
|
59
|
+
*
|
|
60
|
+
* The credential itself is NOT in the returned URL — it is the bare
|
|
61
|
+
* `x-access-token` username only (the token is passed via a HOST-BOUND
|
|
62
|
+
* GIT_ASKPASS helper, see cloneRepo / createAskpassScript). So the token never
|
|
63
|
+
* lands on the git argv or in the cloned `.git/config` remote URL (MEDIUM-2/3).
|
|
64
|
+
*
|
|
65
|
+
* No `-c` git config is returned: the earlier `-c credential.helper=` was
|
|
66
|
+
* REJECTED by simple-git's safety guard (allowUnsafeCredentialHelper), which
|
|
67
|
+
* aborted the clone entirely; and `-c http.followRedirects=false` is dropped
|
|
68
|
+
* per review finding R-1 (it blocked legitimate same-host redirects too). The
|
|
69
|
+
* host-bound askpass makes following redirects provably safe — the token is
|
|
70
|
+
* emitted ONLY for `host`, and libcurl already strips credentials on a
|
|
71
|
+
* cross-host redirect by default.
|
|
72
|
+
*
|
|
73
|
+
* Returned as plain data so the auth decision is unit-testable without spawning
|
|
74
|
+
* git. The token is intentionally NOT a parameter — it never influences this
|
|
75
|
+
* (URL/argv) surface.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildOverrideCloneAuth(repoUrl: string): {
|
|
78
|
+
cloneUrl: string;
|
|
79
|
+
host: string;
|
|
80
|
+
};
|
|
35
81
|
export declare function cloneRepo(repoUrl: string, targetDir: string, opts?: CloneOptions): Promise<{
|
|
36
82
|
localPath: string;
|
|
37
83
|
branch: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git-utils.d.ts","sourceRoot":"","sources":["../../src/core/git-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAYH,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AASD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAK1E;AA2ED,wBAAsB,YAAY,CAAC,UAAU,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAc7E;AAED,wBAAgB,uBAAuB,IAAI,aAAa,CAyBvD;AAeD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CASjE;AAID,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,YAAY,GAClB,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"git-utils.d.ts","sourceRoot":"","sources":["../../src/core/git-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAYH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,6BAA6B,CAAC;AAE5D;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,4BAA4B,CAAC;AAI1D,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AASD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAK1E;AA2ED,wBAAsB,YAAY,CAAC,UAAU,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAc7E;AAED,wBAAgB,uBAAuB,IAAI,aAAa,CAyBvD;AAeD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CASjE;AAID,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd,CAMA;AAkJD,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,YAAY,GAClB,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAsChD;AAID,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA8B5E;AAID,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1C;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,EAC/C,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,UAAU,CAAC,CA4ErB"}
|
package/dist/core/git-utils.js
CHANGED
|
@@ -49,20 +49,37 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
49
49
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
50
50
|
};
|
|
51
51
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
exports.ASKPASS_HOST_ENV = exports.ASKPASS_TOKEN_ENV = void 0;
|
|
52
53
|
exports.scrubCredentials = scrubCredentials;
|
|
53
54
|
exports.getAuthenticatedUrl = getAuthenticatedUrl;
|
|
54
55
|
exports.getAuthToken = getAuthToken;
|
|
55
56
|
exports.getGitAuthConfigFromEnv = getGitAuthConfigFromEnv;
|
|
56
57
|
exports.sanitizeRelativePath = sanitizeRelativePath;
|
|
58
|
+
exports.buildOverrideCloneAuth = buildOverrideCloneAuth;
|
|
57
59
|
exports.cloneRepo = cloneRepo;
|
|
58
60
|
exports.pullRepo = pullRepo;
|
|
59
61
|
exports.pushRepo = pushRepo;
|
|
60
62
|
const simple_git_1 = __importDefault(require("simple-git"));
|
|
63
|
+
const node_child_process_1 = require("node:child_process");
|
|
61
64
|
const jwt = __importStar(require("jsonwebtoken"));
|
|
62
65
|
const fs = __importStar(require("fs"));
|
|
63
66
|
const path = __importStar(require("path"));
|
|
67
|
+
const os = __importStar(require("os"));
|
|
64
68
|
const FETCH_TIMEOUT_MS = 30000;
|
|
65
69
|
const GIT_TIMEOUT_MS = 120000; // 2 minutes for git operations
|
|
70
|
+
/**
|
|
71
|
+
* Environment variable name through which a per-request override credential
|
|
72
|
+
* (PRD #621 M3) is handed to the GIT_ASKPASS helper. The token travels in the
|
|
73
|
+
* git child process's ENVIRONMENT — never on its argv (ps/proc) and never
|
|
74
|
+
* embedded in the clone URL written to `.git/config`.
|
|
75
|
+
*/
|
|
76
|
+
exports.ASKPASS_TOKEN_ENV = 'DOT_AI_GIT_ASKPASS_TOKEN';
|
|
77
|
+
/**
|
|
78
|
+
* Environment variable naming the host the override token is bound to. The
|
|
79
|
+
* GIT_ASKPASS helper emits the token ONLY when git's credential prompt names
|
|
80
|
+
* this host, so a cross-host HTTP redirect can never obtain it (Decision 3).
|
|
81
|
+
*/
|
|
82
|
+
exports.ASKPASS_HOST_ENV = 'DOT_AI_GIT_ASKPASS_HOST';
|
|
66
83
|
// ─── Auth helpers ───
|
|
67
84
|
function scrubCredentials(message) {
|
|
68
85
|
return message
|
|
@@ -177,10 +194,184 @@ function sanitizeRelativePath(relativePath) {
|
|
|
177
194
|
}
|
|
178
195
|
return normalized;
|
|
179
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* PRD #621 M3 / Decision 3: build the clone URL + intended host for a
|
|
199
|
+
* per-request override credential.
|
|
200
|
+
*
|
|
201
|
+
* The credential itself is NOT in the returned URL — it is the bare
|
|
202
|
+
* `x-access-token` username only (the token is passed via a HOST-BOUND
|
|
203
|
+
* GIT_ASKPASS helper, see cloneRepo / createAskpassScript). So the token never
|
|
204
|
+
* lands on the git argv or in the cloned `.git/config` remote URL (MEDIUM-2/3).
|
|
205
|
+
*
|
|
206
|
+
* No `-c` git config is returned: the earlier `-c credential.helper=` was
|
|
207
|
+
* REJECTED by simple-git's safety guard (allowUnsafeCredentialHelper), which
|
|
208
|
+
* aborted the clone entirely; and `-c http.followRedirects=false` is dropped
|
|
209
|
+
* per review finding R-1 (it blocked legitimate same-host redirects too). The
|
|
210
|
+
* host-bound askpass makes following redirects provably safe — the token is
|
|
211
|
+
* emitted ONLY for `host`, and libcurl already strips credentials on a
|
|
212
|
+
* cross-host redirect by default.
|
|
213
|
+
*
|
|
214
|
+
* Returned as plain data so the auth decision is unit-testable without spawning
|
|
215
|
+
* git. The token is intentionally NOT a parameter — it never influences this
|
|
216
|
+
* (URL/argv) surface.
|
|
217
|
+
*/
|
|
218
|
+
function buildOverrideCloneAuth(repoUrl) {
|
|
219
|
+
const url = new URL(repoUrl);
|
|
220
|
+
const host = url.host;
|
|
221
|
+
url.username = 'x-access-token';
|
|
222
|
+
url.password = '';
|
|
223
|
+
return { cloneUrl: url.toString(), host };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Create a throwaway, HOST-BOUND GIT_ASKPASS helper script. The script holds NO
|
|
227
|
+
* secret — it echoes the token from the environment (ASKPASS_TOKEN_ENV) ONLY
|
|
228
|
+
* when git's credential prompt (passed as $1) names the intended host
|
|
229
|
+
* (ASKPASS_HOST_ENV), delimited by `@`/`//` before and a closing quote after.
|
|
230
|
+
* For any other host — e.g. after an HTTP redirect, or a look-alike like
|
|
231
|
+
* `github.com.evil.test` — it emits nothing, so the token can never reach a
|
|
232
|
+
* different host (Decision 3). The token never touches disk. The script lives
|
|
233
|
+
* in its own 0700 temp dir; `cleanup` removes it.
|
|
234
|
+
*/
|
|
235
|
+
function createAskpassScript() {
|
|
236
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dot-ai-askpass-'));
|
|
237
|
+
try {
|
|
238
|
+
fs.chmodSync(dir, 0o700);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* best-effort hardening */
|
|
242
|
+
}
|
|
243
|
+
const scriptPath = path.join(dir, 'askpass.sh');
|
|
244
|
+
// Host-bound match: require the intended host immediately after `@` or `//`
|
|
245
|
+
// and immediately before the closing `'` git puts around the URL, so neither
|
|
246
|
+
// a different redirect host nor a look-alike suffix matches.
|
|
247
|
+
const script = [
|
|
248
|
+
'#!/bin/sh',
|
|
249
|
+
'case "$1" in',
|
|
250
|
+
` *"@$${exports.ASKPASS_HOST_ENV}'"*|*"//$${exports.ASKPASS_HOST_ENV}'"*)`,
|
|
251
|
+
` printf '%s\\n' "$${exports.ASKPASS_TOKEN_ENV}"`,
|
|
252
|
+
' ;;',
|
|
253
|
+
'esac',
|
|
254
|
+
'',
|
|
255
|
+
].join('\n');
|
|
256
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o700 });
|
|
257
|
+
return {
|
|
258
|
+
scriptPath,
|
|
259
|
+
cleanup: () => {
|
|
260
|
+
try {
|
|
261
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* best-effort cleanup; the script holds no secret */
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* PRD #621 M3: clone an OVERRIDE repo using a per-request token, via a
|
|
271
|
+
* HOST-BOUND GIT_ASKPASS helper.
|
|
272
|
+
*
|
|
273
|
+
* This deliberately spawns `git` DIRECTLY rather than going through simple-git:
|
|
274
|
+
* simple-git's safety scanner rejects the env vars this approach relies on
|
|
275
|
+
* (GIT_ASKPASS → allowUnsafeAskPass) and even flags inherited vars like EDITOR
|
|
276
|
+
* / PAGER, which aborts the clone before it starts. A direct spawn lets us pass
|
|
277
|
+
* the full process.env (PATH/HOME/proxy/TLS) plus the askpass wiring with no
|
|
278
|
+
* argument/env guard interference, while still keeping:
|
|
279
|
+
* - the token OFF the argv (the URL carries only the `x-access-token`
|
|
280
|
+
* username) and OUT of .git/config (MEDIUM-2/MEDIUM-3);
|
|
281
|
+
* - the token bound to the source host so a cross-host redirect can't obtain
|
|
282
|
+
* it (Decision 3 — host-bound askpass + libcurl's default cross-host
|
|
283
|
+
* credential stripping). Redirects are NOT disabled (review finding R-1),
|
|
284
|
+
* so legitimate same-host redirects still work.
|
|
285
|
+
*/
|
|
286
|
+
async function cloneWithOverrideToken(repoUrl, targetDir, opts) {
|
|
287
|
+
const { cloneUrl, host } = buildOverrideCloneAuth(repoUrl);
|
|
288
|
+
const askpass = createAskpassScript();
|
|
289
|
+
const args = ['clone'];
|
|
290
|
+
if (opts.branch) {
|
|
291
|
+
args.push('--branch', opts.branch);
|
|
292
|
+
}
|
|
293
|
+
if (opts.depth) {
|
|
294
|
+
args.push('--depth', String(opts.depth));
|
|
295
|
+
}
|
|
296
|
+
// `--` terminates option parsing so the URL/dir can never be read as flags.
|
|
297
|
+
args.push('--', cloneUrl, targetDir);
|
|
298
|
+
const env = {
|
|
299
|
+
...process.env,
|
|
300
|
+
GIT_ASKPASS: askpass.scriptPath,
|
|
301
|
+
// Never fall back to an interactive terminal prompt if askpass yields nothing.
|
|
302
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
303
|
+
[exports.ASKPASS_TOKEN_ENV]: opts.token,
|
|
304
|
+
[exports.ASKPASS_HOST_ENV]: host,
|
|
305
|
+
};
|
|
306
|
+
try {
|
|
307
|
+
await new Promise((resolve, reject) => {
|
|
308
|
+
const child = (0, node_child_process_1.spawn)('git', args, {
|
|
309
|
+
env,
|
|
310
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
311
|
+
});
|
|
312
|
+
let stderr = '';
|
|
313
|
+
// The 'error', 'close', and timeout handlers can each race to settle this
|
|
314
|
+
// promise (e.g. 'close' still fires after a kill or a spawn 'error'). A
|
|
315
|
+
// promise only settles once, but the LATER handlers would still run their
|
|
316
|
+
// logic on an already-settled promise. Guard so the FIRST settle wins and
|
|
317
|
+
// every subsequent handler is a no-op, and clear the timeout on settle so
|
|
318
|
+
// no dangling timer fires afterwards.
|
|
319
|
+
let settled = false;
|
|
320
|
+
const timerRef = {};
|
|
321
|
+
const settle = (action) => {
|
|
322
|
+
if (settled)
|
|
323
|
+
return;
|
|
324
|
+
settled = true;
|
|
325
|
+
if (timerRef.id)
|
|
326
|
+
clearTimeout(timerRef.id);
|
|
327
|
+
action();
|
|
328
|
+
};
|
|
329
|
+
timerRef.id = setTimeout(() => {
|
|
330
|
+
settle(() => {
|
|
331
|
+
child.kill('SIGKILL');
|
|
332
|
+
reject(new Error(`git clone timed out after ${GIT_TIMEOUT_MS}ms`));
|
|
333
|
+
});
|
|
334
|
+
}, GIT_TIMEOUT_MS);
|
|
335
|
+
child.stderr?.on('data', chunk => {
|
|
336
|
+
stderr += chunk.toString();
|
|
337
|
+
});
|
|
338
|
+
child.on('error', err => {
|
|
339
|
+
settle(() => reject(err));
|
|
340
|
+
});
|
|
341
|
+
child.on('close', code => {
|
|
342
|
+
settle(() => {
|
|
343
|
+
if (code === 0) {
|
|
344
|
+
resolve();
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// stderr carries only the username-only URL (no token), so it is
|
|
348
|
+
// safe to surface; the caller scrubs it again as defense-in-depth.
|
|
349
|
+
reject(new Error(`git clone exited with code ${code}: ${stderr.trim()}`));
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
// Remove the askpass helper as soon as the clone finishes (success or
|
|
357
|
+
// failure). It holds no secret, but leaving temp files around is untidy.
|
|
358
|
+
askpass.cleanup();
|
|
359
|
+
}
|
|
360
|
+
return { localPath: targetDir, branch: opts.branch || 'main' };
|
|
361
|
+
}
|
|
180
362
|
async function cloneRepo(repoUrl, targetDir, opts) {
|
|
363
|
+
// PRD #621 M3 / Decision 4: a per-request override credential takes precedence
|
|
364
|
+
// over env auth for THIS clone only and uses the host-bound GIT_ASKPASS path.
|
|
365
|
+
if (opts?.token) {
|
|
366
|
+
return cloneWithOverrideToken(repoUrl, targetDir, {
|
|
367
|
+
...opts,
|
|
368
|
+
token: opts.token,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
// Env/GitHub-App auth path (unchanged): credentials come from
|
|
372
|
+
// getGitAuthConfigFromEnv and are embedded in the URL as before.
|
|
181
373
|
const authConfig = getGitAuthConfigFromEnv();
|
|
182
374
|
let cloneUrl;
|
|
183
|
-
// Use authenticated URL if credentials are available, otherwise clone unauthenticated (public repos)
|
|
184
375
|
if (authConfig.pat || authConfig.githubApp) {
|
|
185
376
|
const token = await getAuthToken(authConfig);
|
|
186
377
|
cloneUrl = getAuthenticatedUrl(repoUrl, token);
|
|
@@ -32,6 +32,15 @@ export interface UserPromptsOverride {
|
|
|
32
32
|
repoUrl: string;
|
|
33
33
|
branch?: string;
|
|
34
34
|
subPath?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Per-request git credential forwarded by the caller (PRD #621 M2/M3, via the
|
|
37
|
+
* X-Dot-AI-Git-Token header). When present it takes precedence over
|
|
38
|
+
* DOT_AI_GIT_TOKEN for THIS request only (Decision 4) and triggers per-request
|
|
39
|
+
* cache isolation so a private authenticated clone is never served to/from the
|
|
40
|
+
* shared unauthenticated cache slot (Decision 2). Never enters the cache key
|
|
41
|
+
* or any log/error/source surface.
|
|
42
|
+
*/
|
|
43
|
+
gitToken?: string;
|
|
35
44
|
}
|
|
36
45
|
/**
|
|
37
46
|
* Cache state for tracking repository freshness.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-prompts-loader.d.ts","sourceRoot":"","sources":["../../src/core/user-prompts-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;
|
|
1
|
+
{"version":3,"file":"user-prompts-loader.d.ts","sourceRoot":"","sources":["../../src/core/user-prompts-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAO1C,OAAO,EAAE,MAAM,EAA8B,MAAM,kBAAkB,CAAC;AAEtE;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,UAAU,UAAU;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AAKD;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,iBAAiB,GAAG,IAAI,CAsB/D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,CAAC,EAAE,mBAAmB,GAAG,MAAM,CAS3E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gCAAgC,CAC9C,QAAQ,EAAE,mBAAmB,GAC5B,iBAAiB,CAqDnB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAqB1C;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAUzD;AAQD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAoBlD;AA6VD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,YAAY,GAAE,OAAe,EAC7B,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,MAAM,EAAE,CAAC,CA0JnB;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;GAEG;AACH,wBAAgB,wBAAwB,IAAI,UAAU,GAAG,IAAI,CAE5D"}
|
|
@@ -58,6 +58,7 @@ exports.getUserPromptsCacheState = getUserPromptsCacheState;
|
|
|
58
58
|
const fs = __importStar(require("fs"));
|
|
59
59
|
const path = __importStar(require("path"));
|
|
60
60
|
const os = __importStar(require("os"));
|
|
61
|
+
const crypto = __importStar(require("crypto"));
|
|
61
62
|
const git_utils_1 = require("./git-utils");
|
|
62
63
|
const prompts_1 = require("../tools/prompts");
|
|
63
64
|
// In-memory cache state (persists across requests within same process)
|
|
@@ -160,7 +161,10 @@ function getUserPromptsConfigFromOverride(override) {
|
|
|
160
161
|
repoUrl: override.repoUrl,
|
|
161
162
|
branch,
|
|
162
163
|
subPath: normalizedSubPath,
|
|
163
|
-
|
|
164
|
+
// PRD #621 M2 / Decision 4: a request-supplied token (override.gitToken)
|
|
165
|
+
// takes precedence over the server env credential for this request only;
|
|
166
|
+
// the env credential remains the fallback when no header is present.
|
|
167
|
+
gitToken: override.gitToken ?? process.env.DOT_AI_GIT_TOKEN,
|
|
164
168
|
cacheTtlSeconds,
|
|
165
169
|
};
|
|
166
170
|
}
|
|
@@ -254,9 +258,36 @@ function isValidGitBranch(branch) {
|
|
|
254
258
|
return /^[a-zA-Z0-9_.\-/]+$/.test(branch);
|
|
255
259
|
}
|
|
256
260
|
/**
|
|
257
|
-
*
|
|
261
|
+
* Create a unique, throwaway ROOT directory for a token-bearing override
|
|
262
|
+
* request (PRD #621 M3 / Decision 2). It lives alongside the shared cache
|
|
263
|
+
* directory but is NOT the shared slot, so an authenticated private clone is
|
|
264
|
+
* never written to (or served from) the unauthenticated cache. The caller
|
|
265
|
+
* removes it after reading.
|
|
266
|
+
*
|
|
267
|
+
* Hardening (LOW-4): created atomically via fs.mkdtempSync with a CSPRNG
|
|
268
|
+
* (crypto.randomUUID) name component and mode 0700, so the authenticated clone
|
|
269
|
+
* cannot land in a predictable, world-readable location.
|
|
270
|
+
*/
|
|
271
|
+
function createIsolatedCloneRoot() {
|
|
272
|
+
const parent = path.dirname(getCacheDirectory());
|
|
273
|
+
const root = fs.mkdtempSync(path.join(parent, `user-prompts-override-${crypto.randomUUID()}-`));
|
|
274
|
+
try {
|
|
275
|
+
fs.chmodSync(root, 0o700);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* best-effort hardening (mkdtempSync already creates with 0700) */
|
|
279
|
+
}
|
|
280
|
+
return root;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Clone the user prompts repository.
|
|
284
|
+
*
|
|
285
|
+
* `overrideToken` (PRD #621 M3) is a per-request credential that, when present,
|
|
286
|
+
* overrides env auth for this clone only and is scoped to the source host with
|
|
287
|
+
* no cross-host redirect forwarding (handled in cloneRepo). When omitted, the
|
|
288
|
+
* clone authenticates via env exactly as before.
|
|
258
289
|
*/
|
|
259
|
-
async function cloneRepository(config, localPath, logger) {
|
|
290
|
+
async function cloneRepository(config, localPath, logger, overrideToken) {
|
|
260
291
|
// Validate branch name as defense-in-depth
|
|
261
292
|
if (!isValidGitBranch(config.branch)) {
|
|
262
293
|
throw new Error(`Invalid branch name: ${config.branch}`);
|
|
@@ -280,6 +311,9 @@ async function cloneRepository(config, localPath, logger) {
|
|
|
280
311
|
await (0, git_utils_1.cloneRepo)(config.repoUrl, localPath, {
|
|
281
312
|
branch: config.branch,
|
|
282
313
|
depth: 1,
|
|
314
|
+
// Per-request override credential (PRD #621 M3). undefined → cloneRepo
|
|
315
|
+
// falls back to env auth, i.e. today's behavior unchanged.
|
|
316
|
+
token: overrideToken,
|
|
283
317
|
});
|
|
284
318
|
logger.info('Successfully cloned user prompts repository', {
|
|
285
319
|
url: sanitizedUrl,
|
|
@@ -287,14 +321,24 @@ async function cloneRepository(config, localPath, logger) {
|
|
|
287
321
|
});
|
|
288
322
|
}
|
|
289
323
|
catch (error) {
|
|
324
|
+
const scrub = (raw) => (0, git_utils_1.scrubCredentials)(config.gitToken ? raw.replaceAll(config.gitToken, '***') : raw);
|
|
290
325
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
291
|
-
const sanitizedError = (
|
|
292
|
-
? errorMessage.replaceAll(config.gitToken, '***')
|
|
293
|
-
: errorMessage);
|
|
326
|
+
const sanitizedError = scrub(errorMessage);
|
|
294
327
|
logger.error('Failed to clone user prompts repository', new Error(sanitizedError), {
|
|
295
328
|
url: sanitizedUrl,
|
|
296
329
|
branch: config.branch,
|
|
297
330
|
});
|
|
331
|
+
// LOW-5: scrub the caught error IN PLACE (message + stack) before attaching
|
|
332
|
+
// it as `cause`, so a serialized `.cause` cannot leak the token. (With the
|
|
333
|
+
// GIT_ASKPASS rework the override token is no longer on the git argv/URL, so
|
|
334
|
+
// it cannot appear here in the first place — this is defense-in-depth, esp.
|
|
335
|
+
// for the env-credential path which still embeds its token in the URL.)
|
|
336
|
+
if (error instanceof Error) {
|
|
337
|
+
error.message = scrub(error.message);
|
|
338
|
+
if (error.stack) {
|
|
339
|
+
error.stack = scrub(error.stack);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
298
342
|
throw new Error(`Failed to clone user prompts repository: ${sanitizedError}`, { cause: error });
|
|
299
343
|
}
|
|
300
344
|
}
|
|
@@ -326,10 +370,56 @@ async function pullRepository(config, localPath, logger) {
|
|
|
326
370
|
}
|
|
327
371
|
}
|
|
328
372
|
/**
|
|
329
|
-
* Ensure the repository is cloned and up-to-date
|
|
330
|
-
*
|
|
373
|
+
* Ensure the repository is cloned and up-to-date.
|
|
374
|
+
*
|
|
375
|
+
* Returns the path to the prompts directory within the repository, plus an
|
|
376
|
+
* optional `isolatedRoot` the caller must remove after reading (set only for
|
|
377
|
+
* the token-bearing isolation path below).
|
|
378
|
+
*
|
|
379
|
+
* PRD #621 M3 / Decision 2 (cache isolation): when `overrideToken` is present
|
|
380
|
+
* (a request forwarded an X-Dot-AI-Git-Token), the clone is performed into a
|
|
381
|
+
* unique throwaway directory and the shared `cacheState` is neither read nor
|
|
382
|
+
* written. This guarantees an authenticated private clone is never served from
|
|
383
|
+
* — nor written into — the shared unauthenticated cache slot for the same
|
|
384
|
+
* (repoUrl, branch, subPath) coordinate, and that the token never enters the
|
|
385
|
+
* cache key. Token-less requests use the shared cache exactly as before.
|
|
331
386
|
*/
|
|
332
|
-
async function ensureRepository(config, logger, forceRefresh = false) {
|
|
387
|
+
async function ensureRepository(config, logger, forceRefresh = false, overrideToken) {
|
|
388
|
+
if (overrideToken) {
|
|
389
|
+
const isolatedRoot = createIsolatedCloneRoot();
|
|
390
|
+
// Clone into a subdirectory of the 0700 root so the root's restrictive
|
|
391
|
+
// permissions cover the authenticated clone (git creates `cloneDir` itself).
|
|
392
|
+
const cloneDir = path.join(isolatedRoot, 'repo');
|
|
393
|
+
logger.debug('Token-bearing override: cloning in isolation', {
|
|
394
|
+
url: sanitizeUrlForLogging(config.repoUrl),
|
|
395
|
+
branch: config.branch,
|
|
396
|
+
});
|
|
397
|
+
try {
|
|
398
|
+
await cloneRepository(config, cloneDir, logger, overrideToken);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
// Remove any partial clone before propagating so a failed authenticated
|
|
402
|
+
// request leaves no isolated directory behind. With GIT_ASKPASS the token
|
|
403
|
+
// is never written to disk, so a cleanup failure cannot leave a PAT
|
|
404
|
+
// behind — but warn (don't swallow) for observability.
|
|
405
|
+
try {
|
|
406
|
+
fs.rmSync(isolatedRoot, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
catch (cleanupError) {
|
|
409
|
+
logger.warn('Failed to remove isolated clone directory after clone failure', {
|
|
410
|
+
path: isolatedRoot,
|
|
411
|
+
error: cleanupError instanceof Error
|
|
412
|
+
? cleanupError.message
|
|
413
|
+
: String(cleanupError),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
const promptsDir = config.subPath
|
|
419
|
+
? path.join(cloneDir, config.subPath)
|
|
420
|
+
: cloneDir;
|
|
421
|
+
return { promptsDir, isolatedRoot };
|
|
422
|
+
}
|
|
333
423
|
const localPath = getCacheDirectory();
|
|
334
424
|
const now = Date.now();
|
|
335
425
|
const ttlMs = config.cacheTtlSeconds * 1000;
|
|
@@ -375,7 +465,11 @@ async function ensureRepository(config, logger, forceRefresh = false) {
|
|
|
375
465
|
});
|
|
376
466
|
}
|
|
377
467
|
// Return path to prompts directory (with optional subPath)
|
|
378
|
-
return
|
|
468
|
+
return {
|
|
469
|
+
promptsDir: config.subPath
|
|
470
|
+
? path.join(localPath, config.subPath)
|
|
471
|
+
: localPath,
|
|
472
|
+
};
|
|
379
473
|
}
|
|
380
474
|
const SKILL_FILE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB per file (before base64 encoding)
|
|
381
475
|
const SKILL_FILENAME = 'SKILL.md';
|
|
@@ -458,8 +552,16 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
458
552
|
logger.debug('User prompts not configured (DOT_AI_USER_PROMPTS_REPO not set)');
|
|
459
553
|
return [];
|
|
460
554
|
}
|
|
555
|
+
// PRD #621 M3 / Decision 2: a request-forwarded credential (override.gitToken)
|
|
556
|
+
// triggers per-request cache isolation. Track the throwaway clone directory so
|
|
557
|
+
// it is removed after the read (success-path cleanup; the failure path is
|
|
558
|
+
// cleaned up inside ensureRepository).
|
|
559
|
+
const overrideToken = override?.gitToken;
|
|
560
|
+
let isolatedRoot;
|
|
461
561
|
try {
|
|
462
|
-
const
|
|
562
|
+
const ensured = await ensureRepository(config, logger, forceRefresh, overrideToken);
|
|
563
|
+
const promptsDir = ensured.promptsDir;
|
|
564
|
+
isolatedRoot = ensured.isolatedRoot;
|
|
463
565
|
if (!fs.existsSync(promptsDir)) {
|
|
464
566
|
logger.warn('User prompts directory not found in repository', {
|
|
465
567
|
path: promptsDir,
|
|
@@ -539,6 +641,25 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
|
|
|
539
641
|
logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
|
|
540
642
|
return [];
|
|
541
643
|
}
|
|
644
|
+
finally {
|
|
645
|
+
// PRD #621 M3 / Decision 2: remove the per-request isolated clone (if any)
|
|
646
|
+
// so token-bearing override clones leave no on-disk residue. With
|
|
647
|
+
// GIT_ASKPASS the token is never written to disk, so a failed cleanup
|
|
648
|
+
// cannot leave a PAT behind — but warn (don't swallow) for observability.
|
|
649
|
+
if (isolatedRoot) {
|
|
650
|
+
try {
|
|
651
|
+
fs.rmSync(isolatedRoot, { recursive: true, force: true });
|
|
652
|
+
}
|
|
653
|
+
catch (cleanupError) {
|
|
654
|
+
logger.warn('Failed to remove isolated clone directory', {
|
|
655
|
+
path: isolatedRoot,
|
|
656
|
+
error: cleanupError instanceof Error
|
|
657
|
+
? cleanupError.message
|
|
658
|
+
: String(cleanupError),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
542
663
|
}
|
|
543
664
|
/**
|
|
544
665
|
* Clear the cache state (useful for testing)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CORS allow-header lists (PRD #621 M2, Decision 1).
|
|
3
|
+
*
|
|
4
|
+
* The REST (src/interfaces/rest-api.ts) and front HTTP (src/interfaces/mcp.ts)
|
|
5
|
+
* layers each answer CORS preflight with their own `Access-Control-Allow-Headers`
|
|
6
|
+
* value, and the two were historically OUT OF SYNC. Centralizing the credential
|
|
7
|
+
* header name here guarantees the new `X-Dot-AI-Git-Token` header is advertised
|
|
8
|
+
* by BOTH preflight responses (Decision 1) and that the two lists can never
|
|
9
|
+
* silently drift apart on this header again.
|
|
10
|
+
*
|
|
11
|
+
* The two lists intentionally differ on the OTHER headers (mcp.ts also allows
|
|
12
|
+
* X-Session-Id / X-Dot-AI-Authorization), which is preserved.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Per-request git credential header (PRD #621 M2/M3, Decision 1). The CLI
|
|
16
|
+
* forwards its `DOT_AI_GIT_TOKEN` here so the server can authenticate an
|
|
17
|
+
* overridden (`?repo=`) clone against a second auth realm. Always a request
|
|
18
|
+
* header — never a query param or body field.
|
|
19
|
+
*/
|
|
20
|
+
export declare const GIT_TOKEN_HEADER = "X-Dot-AI-Git-Token";
|
|
21
|
+
/**
|
|
22
|
+
* Lowercased form for reading the header off Node's `req.headers` (Node
|
|
23
|
+
* lowercases all incoming header names).
|
|
24
|
+
*/
|
|
25
|
+
export declare const GIT_TOKEN_HEADER_LC: string;
|
|
26
|
+
/**
|
|
27
|
+
* `Access-Control-Allow-Headers` value for the REST API layer
|
|
28
|
+
* (rest-api.ts `setCorsHeaders`).
|
|
29
|
+
*/
|
|
30
|
+
export declare const REST_CORS_ALLOW_HEADERS = "Content-Type, Authorization, X-Dot-AI-Git-Token";
|
|
31
|
+
/**
|
|
32
|
+
* `Access-Control-Allow-Headers` value for the front HTTP layer (mcp.ts).
|
|
33
|
+
* Retains X-Session-Id and X-Dot-AI-Authorization from the pre-existing list.
|
|
34
|
+
*
|
|
35
|
+
* Includes `Mcp-Session-Id` because the MCP session router in mcp.ts routes
|
|
36
|
+
* requests by `req.headers['mcp-session-id']` (the Streamable HTTP transport's
|
|
37
|
+
* session header); without it a browser preflight requesting that header would
|
|
38
|
+
* fail (CodeRabbit Finding 3). `X-Session-Id` is kept for backward compat.
|
|
39
|
+
*/
|
|
40
|
+
export declare const MCP_CORS_ALLOW_HEADERS = "Content-Type, X-Session-Id, Mcp-Session-Id, Authorization, X-Dot-AI-Authorization, X-Dot-AI-Git-Token";
|
|
41
|
+
//# sourceMappingURL=cors-headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cors-headers.d.ts","sourceRoot":"","sources":["../../src/interfaces/cors-headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,uBAAuB,CAAC;AAErD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,QAAiC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,uBAAuB,oDAAqD,CAAC;AAE1F;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,0GAA2G,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared CORS allow-header lists (PRD #621 M2, Decision 1).
|
|
4
|
+
*
|
|
5
|
+
* The REST (src/interfaces/rest-api.ts) and front HTTP (src/interfaces/mcp.ts)
|
|
6
|
+
* layers each answer CORS preflight with their own `Access-Control-Allow-Headers`
|
|
7
|
+
* value, and the two were historically OUT OF SYNC. Centralizing the credential
|
|
8
|
+
* header name here guarantees the new `X-Dot-AI-Git-Token` header is advertised
|
|
9
|
+
* by BOTH preflight responses (Decision 1) and that the two lists can never
|
|
10
|
+
* silently drift apart on this header again.
|
|
11
|
+
*
|
|
12
|
+
* The two lists intentionally differ on the OTHER headers (mcp.ts also allows
|
|
13
|
+
* X-Session-Id / X-Dot-AI-Authorization), which is preserved.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.MCP_CORS_ALLOW_HEADERS = exports.REST_CORS_ALLOW_HEADERS = exports.GIT_TOKEN_HEADER_LC = exports.GIT_TOKEN_HEADER = void 0;
|
|
17
|
+
/**
|
|
18
|
+
* Per-request git credential header (PRD #621 M2/M3, Decision 1). The CLI
|
|
19
|
+
* forwards its `DOT_AI_GIT_TOKEN` here so the server can authenticate an
|
|
20
|
+
* overridden (`?repo=`) clone against a second auth realm. Always a request
|
|
21
|
+
* header — never a query param or body field.
|
|
22
|
+
*/
|
|
23
|
+
exports.GIT_TOKEN_HEADER = 'X-Dot-AI-Git-Token';
|
|
24
|
+
/**
|
|
25
|
+
* Lowercased form for reading the header off Node's `req.headers` (Node
|
|
26
|
+
* lowercases all incoming header names).
|
|
27
|
+
*/
|
|
28
|
+
exports.GIT_TOKEN_HEADER_LC = exports.GIT_TOKEN_HEADER.toLowerCase();
|
|
29
|
+
/**
|
|
30
|
+
* `Access-Control-Allow-Headers` value for the REST API layer
|
|
31
|
+
* (rest-api.ts `setCorsHeaders`).
|
|
32
|
+
*/
|
|
33
|
+
exports.REST_CORS_ALLOW_HEADERS = `Content-Type, Authorization, ${exports.GIT_TOKEN_HEADER}`;
|
|
34
|
+
/**
|
|
35
|
+
* `Access-Control-Allow-Headers` value for the front HTTP layer (mcp.ts).
|
|
36
|
+
* Retains X-Session-Id and X-Dot-AI-Authorization from the pre-existing list.
|
|
37
|
+
*
|
|
38
|
+
* Includes `Mcp-Session-Id` because the MCP session router in mcp.ts routes
|
|
39
|
+
* requests by `req.headers['mcp-session-id']` (the Streamable HTTP transport's
|
|
40
|
+
* session header); without it a browser preflight requesting that header would
|
|
41
|
+
* fail (CodeRabbit Finding 3). `X-Session-Id` is kept for backward compat.
|
|
42
|
+
*/
|
|
43
|
+
exports.MCP_CORS_ALLOW_HEADERS = `Content-Type, X-Session-Id, Mcp-Session-Id, Authorization, X-Dot-AI-Authorization, ${exports.GIT_TOKEN_HEADER}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of credential-bearing request headers before they are logged.
|
|
3
|
+
*
|
|
4
|
+
* The front HTTP layer (mcp.ts) logs incoming requests at debug level. Logging
|
|
5
|
+
* `req.headers` verbatim would leak credentials — `Authorization`,
|
|
6
|
+
* `X-Dot-AI-Authorization`, and (PRD #621) the per-request `X-Dot-AI-Git-Token`
|
|
7
|
+
* — bypassing the loader-level scrubbing. PRD #621 requires the forwarded token
|
|
8
|
+
* NEVER appear in logs, so these header values are replaced with a fixed
|
|
9
|
+
* placeholder before logging.
|
|
10
|
+
*
|
|
11
|
+
* Kept in its own module so the redaction is unit-testable without importing
|
|
12
|
+
* the heavy mcp.ts server module.
|
|
13
|
+
*/
|
|
14
|
+
/** Fixed placeholder substituted for a redacted header value. */
|
|
15
|
+
export declare const REDACTED_PLACEHOLDER = "***REDACTED***";
|
|
16
|
+
/**
|
|
17
|
+
* Lowercased names of headers whose values are credential-bearing and must be
|
|
18
|
+
* redacted before logging. Node lowercases all incoming header names, so the
|
|
19
|
+
* comparison is done in lowercase.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SENSITIVE_HEADER_NAMES: ReadonlySet<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Return a shallow copy of `headers` with the value of every credential-bearing
|
|
24
|
+
* header replaced by REDACTED_PLACEHOLDER. Non-sensitive headers are preserved
|
|
25
|
+
* verbatim. The input object is never mutated.
|
|
26
|
+
*/
|
|
27
|
+
export declare function redactSensitiveHeaders(headers: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
28
|
+
//# sourceMappingURL=header-redaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"header-redaction.d.ts","sourceRoot":"","sources":["../../src/interfaces/header-redaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,iEAAiE;AACjE,eAAO,MAAM,oBAAoB,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAMrD,CAAC;AAEH;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAC3C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redaction of credential-bearing request headers before they are logged.
|
|
4
|
+
*
|
|
5
|
+
* The front HTTP layer (mcp.ts) logs incoming requests at debug level. Logging
|
|
6
|
+
* `req.headers` verbatim would leak credentials — `Authorization`,
|
|
7
|
+
* `X-Dot-AI-Authorization`, and (PRD #621) the per-request `X-Dot-AI-Git-Token`
|
|
8
|
+
* — bypassing the loader-level scrubbing. PRD #621 requires the forwarded token
|
|
9
|
+
* NEVER appear in logs, so these header values are replaced with a fixed
|
|
10
|
+
* placeholder before logging.
|
|
11
|
+
*
|
|
12
|
+
* Kept in its own module so the redaction is unit-testable without importing
|
|
13
|
+
* the heavy mcp.ts server module.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.SENSITIVE_HEADER_NAMES = exports.REDACTED_PLACEHOLDER = void 0;
|
|
17
|
+
exports.redactSensitiveHeaders = redactSensitiveHeaders;
|
|
18
|
+
const cors_headers_1 = require("./cors-headers");
|
|
19
|
+
/** Fixed placeholder substituted for a redacted header value. */
|
|
20
|
+
exports.REDACTED_PLACEHOLDER = '***REDACTED***';
|
|
21
|
+
/**
|
|
22
|
+
* Lowercased names of headers whose values are credential-bearing and must be
|
|
23
|
+
* redacted before logging. Node lowercases all incoming header names, so the
|
|
24
|
+
* comparison is done in lowercase.
|
|
25
|
+
*/
|
|
26
|
+
exports.SENSITIVE_HEADER_NAMES = new Set([
|
|
27
|
+
'authorization',
|
|
28
|
+
'x-dot-ai-authorization',
|
|
29
|
+
cors_headers_1.GIT_TOKEN_HEADER_LC,
|
|
30
|
+
'cookie',
|
|
31
|
+
'proxy-authorization',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Return a shallow copy of `headers` with the value of every credential-bearing
|
|
35
|
+
* header replaced by REDACTED_PLACEHOLDER. Non-sensitive headers are preserved
|
|
36
|
+
* verbatim. The input object is never mutated.
|
|
37
|
+
*/
|
|
38
|
+
function redactSensitiveHeaders(headers) {
|
|
39
|
+
if (!headers)
|
|
40
|
+
return {};
|
|
41
|
+
const redacted = {};
|
|
42
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
43
|
+
redacted[name] = exports.SENSITIVE_HEADER_NAMES.has(name.toLowerCase())
|
|
44
|
+
? exports.REDACTED_PLACEHOLDER
|
|
45
|
+
: value;
|
|
46
|
+
}
|
|
47
|
+
return redacted;
|
|
48
|
+
}
|
|
@@ -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;
|
|
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;AAmBD,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;YAgSlB,gBAAgB;IAexB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoC3B,OAAO,IAAI,OAAO;CAGnB"}
|
package/dist/interfaces/mcp.js
CHANGED
|
@@ -28,6 +28,8 @@ const impact_analysis_1 = require("../tools/impact-analysis");
|
|
|
28
28
|
const prompts_1 = require("../tools/prompts");
|
|
29
29
|
const rest_registry_1 = require("./rest-registry");
|
|
30
30
|
const rest_api_1 = require("./rest-api");
|
|
31
|
+
const cors_headers_1 = require("./cors-headers");
|
|
32
|
+
const header_redaction_1 = require("./header-redaction");
|
|
31
33
|
const oauth_1 = require("./oauth");
|
|
32
34
|
const request_context_1 = require("./request-context");
|
|
33
35
|
const rbac_1 = require("../core/rbac");
|
|
@@ -423,15 +425,23 @@ class MCPServer {
|
|
|
423
425
|
// Execute entire request within the span's context for proper propagation
|
|
424
426
|
await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), span), async () => {
|
|
425
427
|
try {
|
|
428
|
+
// PRD #621: the forwarded token must never appear in logs. Redact
|
|
429
|
+
// credential-bearing headers (HIGH-1) AND scrub credentials from the
|
|
430
|
+
// request URL — a ?repo=https://user:token@host or a
|
|
431
|
+
// credential-bearing query param would otherwise leak verbatim
|
|
432
|
+
// (CodeRabbit Finding 1). sanitizeRequestUrlForLogging is the shared
|
|
433
|
+
// helper already used by the REST layer.
|
|
426
434
|
this.logger.debug('HTTP request received', {
|
|
427
435
|
method: req.method,
|
|
428
|
-
url: req.url,
|
|
429
|
-
headers: req.headers,
|
|
436
|
+
url: (0, rest_api_1.sanitizeRequestUrlForLogging)(req.url),
|
|
437
|
+
headers: (0, header_redaction_1.redactSensitiveHeaders)(req.headers),
|
|
430
438
|
});
|
|
431
439
|
// Handle CORS for browser-based clients
|
|
432
440
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
433
441
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
434
|
-
|
|
442
|
+
// PRD #621 M2 / Decision 1: includes X-Dot-AI-Git-Token, kept in
|
|
443
|
+
// sync with the REST allowlist via cors-headers.ts.
|
|
444
|
+
res.setHeader('Access-Control-Allow-Headers', cors_headers_1.MCP_CORS_ALLOW_HEADERS);
|
|
435
445
|
if (req.method === 'OPTIONS') {
|
|
436
446
|
res.writeHead(204);
|
|
437
447
|
res.end();
|
|
@@ -9,6 +9,7 @@ import { RestToolRegistry, ToolInfo } from './rest-registry';
|
|
|
9
9
|
import { RestRouteRegistry } from './rest-route-registry';
|
|
10
10
|
import { Logger } from '../core/error-handling';
|
|
11
11
|
import { DotAI } from '../core/index';
|
|
12
|
+
import { UserPromptsOverride } from '../core/user-prompts-loader';
|
|
12
13
|
import { PluginManager } from '../core/plugin-manager';
|
|
13
14
|
/**
|
|
14
15
|
* Constant placeholder used when the request URL fails to parse and the
|
|
@@ -31,6 +32,47 @@ export declare const UNPARSEABLE_QUERY_PLACEHOLDER = "?<redacted-unparseable>";
|
|
|
31
32
|
* '?' are pass-through (no risk).
|
|
32
33
|
*/
|
|
33
34
|
export declare function sanitizeRequestUrlForLogging(url: string | undefined): string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Extract and validate the per-request prompts override.
|
|
37
|
+
*
|
|
38
|
+
* Threads three optional, additive inputs from the request into a
|
|
39
|
+
* UserPromptsOverride (PRD #581 introduced `repo`; PRD #621 M1 adds `path`
|
|
40
|
+
* and `branch`):
|
|
41
|
+
* - repoParam → override.repoUrl (GET ?repo= / POST body `repo`)
|
|
42
|
+
* - pathParam → override.subPath (GET ?path= / POST body `path`)
|
|
43
|
+
* - branchParam → override.branch (GET ?branch= / POST body `branch`)
|
|
44
|
+
* - gitToken → override.gitToken (X-Dot-AI-Git-Token request header; M2)
|
|
45
|
+
*
|
|
46
|
+
* Returns:
|
|
47
|
+
* - { ok: true, override } when no `repo` is supplied (override undefined;
|
|
48
|
+
* any path/branch/token are ignored, since they only qualify an override —
|
|
49
|
+
* this keeps the no-`repo` / env-var-configured path unchanged).
|
|
50
|
+
* - { ok: true, override } when a syntactically valid override is supplied.
|
|
51
|
+
* - { ok: false, message } when the override fails validation (HTTP 400).
|
|
52
|
+
*
|
|
53
|
+
* The validation message is run through scrubCredentials so embedded tokens
|
|
54
|
+
* never reach the wire response.
|
|
55
|
+
*
|
|
56
|
+
* Backward compatibility (PRD #621, non-negotiable): when path/branch are
|
|
57
|
+
* absent or empty, the override carries `repoUrl` only — byte-identical to
|
|
58
|
+
* the PRD #581 behavior (same clone target: repo root, `main`). subPath and
|
|
59
|
+
* branch are populated ONLY for a non-empty value, so downstream defaults are
|
|
60
|
+
* untouched. The credential header is INERT unless a `?repo=` override is
|
|
61
|
+
* present: without a repo this returns `override: undefined` before the token
|
|
62
|
+
* is ever read, so the env-var path is unaffected by a forwarded header.
|
|
63
|
+
*
|
|
64
|
+
* Validation is delegated to getUserPromptsConfigFromOverride (scheme,
|
|
65
|
+
* sanitizeRelativePath for subPath, isValidGitBranch for branch) and happens
|
|
66
|
+
* BEFORE any clone or shared-cache mutation, so a rejected override can never
|
|
67
|
+
* corrupt the env-var-configured cache.
|
|
68
|
+
*/
|
|
69
|
+
export declare function extractPromptsOverride(repoParam: unknown, pathParam?: unknown, branchParam?: unknown, gitToken?: string): {
|
|
70
|
+
ok: true;
|
|
71
|
+
override?: UserPromptsOverride;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
message: string;
|
|
75
|
+
};
|
|
34
76
|
/**
|
|
35
77
|
* HTTP status codes for REST responses
|
|
36
78
|
*/
|
|
@@ -233,18 +275,6 @@ export declare class RestApiRouter {
|
|
|
233
275
|
* Returns container logs for a pod
|
|
234
276
|
*/
|
|
235
277
|
private handleGetLogs;
|
|
236
|
-
/**
|
|
237
|
-
* Extract and validate the per-request `repo` override (PRD #581).
|
|
238
|
-
*
|
|
239
|
-
* Returns:
|
|
240
|
-
* - { ok: true, override } when no repo param is supplied, override is undefined.
|
|
241
|
-
* - { ok: true, override } when a syntactically valid override URL is supplied.
|
|
242
|
-
* - { ok: false, message } when the override fails validation (HTTP 400).
|
|
243
|
-
*
|
|
244
|
-
* The validation message is run through scrubCredentials so embedded tokens
|
|
245
|
-
* never reach the wire response.
|
|
246
|
-
*/
|
|
247
|
-
private extractPromptsOverride;
|
|
248
278
|
/**
|
|
249
279
|
* Handle prompts list requests
|
|
250
280
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rest-api.d.ts","sourceRoot":"","sources":["../../src/interfaces/rest-api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EAAE,iBAAiB,EAAc,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"rest-api.d.ts","sourceRoot":"","sources":["../../src/interfaces/rest-api.ts"],"names":[],"mappings":"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"}
|
|
@@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.RestApiRouter = exports.HttpStatus = exports.UNPARSEABLE_QUERY_PLACEHOLDER = void 0;
|
|
43
43
|
exports.sanitizeRequestUrlForLogging = sanitizeRequestUrlForLogging;
|
|
44
|
+
exports.extractPromptsOverride = extractPromptsOverride;
|
|
44
45
|
const node_url_1 = require("node:url");
|
|
45
46
|
const openapi_generator_1 = require("./openapi-generator");
|
|
46
47
|
const rest_route_registry_1 = require("./rest-route-registry");
|
|
@@ -50,6 +51,7 @@ const embedding_migration_handler_1 = require("./embedding-migration-handler");
|
|
|
50
51
|
const prompts_1 = require("../tools/prompts");
|
|
51
52
|
const user_prompts_loader_1 = require("../core/user-prompts-loader");
|
|
52
53
|
const git_utils_1 = require("../core/git-utils");
|
|
54
|
+
const cors_headers_1 = require("./cors-headers");
|
|
53
55
|
const generic_session_manager_1 = require("../core/generic-session-manager");
|
|
54
56
|
const session_events_1 = require("../core/session-events");
|
|
55
57
|
const shared_prompt_loader_1 = require("../core/shared-prompt-loader");
|
|
@@ -108,6 +110,135 @@ function sanitizeRequestUrlForLogging(url) {
|
|
|
108
110
|
return url.slice(0, qIdx) + exports.UNPARSEABLE_QUERY_PLACEHOLDER;
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Coerce an optional override string param (path/branch) supplied via query
|
|
115
|
+
* string or JSON body. Mirrors the `repo` guard in extractPromptsOverride:
|
|
116
|
+
* - non-string (array, number, object, boolean) → 400 (avoids a 500 from a
|
|
117
|
+
* malformed body reaching downstream code).
|
|
118
|
+
* - absent (null/undefined) or empty/whitespace-only → `undefined`, i.e.
|
|
119
|
+
* treated as not supplied so the downstream default (subPath '' / branch
|
|
120
|
+
* 'main') is preserved and an empty-string branch never reaches
|
|
121
|
+
* isValidGitBranch (which would otherwise reject it).
|
|
122
|
+
* - otherwise → the trimmed value.
|
|
123
|
+
*/
|
|
124
|
+
function coerceOverrideStringParam(value, name) {
|
|
125
|
+
if (value === undefined || value === null) {
|
|
126
|
+
return { ok: true, value: undefined };
|
|
127
|
+
}
|
|
128
|
+
if (typeof value !== 'string') {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
message: `${name} must be a string (got ${Array.isArray(value) ? 'array' : typeof value})`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const trimmed = value.trim();
|
|
135
|
+
return { ok: true, value: trimmed.length > 0 ? trimmed : undefined };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read the per-request git credential from the X-Dot-AI-Git-Token header
|
|
139
|
+
* (PRD #621 M2). Node lowercases incoming header names and may present a
|
|
140
|
+
* repeated header as an array; normalize to a single non-empty string or
|
|
141
|
+
* undefined. The value is a secret, so it is never logged here.
|
|
142
|
+
*/
|
|
143
|
+
function readGitTokenHeader(req) {
|
|
144
|
+
const raw = req.headers[cors_headers_1.GIT_TOKEN_HEADER_LC];
|
|
145
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
146
|
+
if (typeof value !== 'string') {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const trimmed = value.trim();
|
|
150
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Extract and validate the per-request prompts override.
|
|
154
|
+
*
|
|
155
|
+
* Threads three optional, additive inputs from the request into a
|
|
156
|
+
* UserPromptsOverride (PRD #581 introduced `repo`; PRD #621 M1 adds `path`
|
|
157
|
+
* and `branch`):
|
|
158
|
+
* - repoParam → override.repoUrl (GET ?repo= / POST body `repo`)
|
|
159
|
+
* - pathParam → override.subPath (GET ?path= / POST body `path`)
|
|
160
|
+
* - branchParam → override.branch (GET ?branch= / POST body `branch`)
|
|
161
|
+
* - gitToken → override.gitToken (X-Dot-AI-Git-Token request header; M2)
|
|
162
|
+
*
|
|
163
|
+
* Returns:
|
|
164
|
+
* - { ok: true, override } when no `repo` is supplied (override undefined;
|
|
165
|
+
* any path/branch/token are ignored, since they only qualify an override —
|
|
166
|
+
* this keeps the no-`repo` / env-var-configured path unchanged).
|
|
167
|
+
* - { ok: true, override } when a syntactically valid override is supplied.
|
|
168
|
+
* - { ok: false, message } when the override fails validation (HTTP 400).
|
|
169
|
+
*
|
|
170
|
+
* The validation message is run through scrubCredentials so embedded tokens
|
|
171
|
+
* never reach the wire response.
|
|
172
|
+
*
|
|
173
|
+
* Backward compatibility (PRD #621, non-negotiable): when path/branch are
|
|
174
|
+
* absent or empty, the override carries `repoUrl` only — byte-identical to
|
|
175
|
+
* the PRD #581 behavior (same clone target: repo root, `main`). subPath and
|
|
176
|
+
* branch are populated ONLY for a non-empty value, so downstream defaults are
|
|
177
|
+
* untouched. The credential header is INERT unless a `?repo=` override is
|
|
178
|
+
* present: without a repo this returns `override: undefined` before the token
|
|
179
|
+
* is ever read, so the env-var path is unaffected by a forwarded header.
|
|
180
|
+
*
|
|
181
|
+
* Validation is delegated to getUserPromptsConfigFromOverride (scheme,
|
|
182
|
+
* sanitizeRelativePath for subPath, isValidGitBranch for branch) and happens
|
|
183
|
+
* BEFORE any clone or shared-cache mutation, so a rejected override can never
|
|
184
|
+
* corrupt the env-var-configured cache.
|
|
185
|
+
*/
|
|
186
|
+
function extractPromptsOverride(repoParam, pathParam, branchParam, gitToken) {
|
|
187
|
+
// Treat absent (null/undefined) repo as no override. path/branch/token only
|
|
188
|
+
// qualify an override, so without a repo they are ignored (the credential
|
|
189
|
+
// header is inert on the env-var path).
|
|
190
|
+
if (repoParam === undefined || repoParam === null) {
|
|
191
|
+
return { ok: true, override: undefined };
|
|
192
|
+
}
|
|
193
|
+
// The wire contract says `repo` is a string. Anything else (array,
|
|
194
|
+
// number, object, boolean) is a 400 — otherwise downstream code
|
|
195
|
+
// would crash to a 500.
|
|
196
|
+
if (typeof repoParam !== 'string') {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
message: `repo must be a string (got ${Array.isArray(repoParam) ? 'array' : typeof repoParam})`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const trimmed = repoParam.trim();
|
|
203
|
+
if (!trimmed) {
|
|
204
|
+
return { ok: true, override: undefined };
|
|
205
|
+
}
|
|
206
|
+
const candidate = { repoUrl: trimmed };
|
|
207
|
+
// PRD #621 M1: thread ?path= / body `path` into subPath (validated
|
|
208
|
+
// downstream by sanitizeRelativePath).
|
|
209
|
+
const pathResult = coerceOverrideStringParam(pathParam, 'path');
|
|
210
|
+
if (!pathResult.ok) {
|
|
211
|
+
return pathResult;
|
|
212
|
+
}
|
|
213
|
+
if (pathResult.value !== undefined) {
|
|
214
|
+
candidate.subPath = pathResult.value;
|
|
215
|
+
}
|
|
216
|
+
// PRD #621 M1: thread ?branch= / body `branch` into branch (validated
|
|
217
|
+
// downstream by isValidGitBranch).
|
|
218
|
+
const branchResult = coerceOverrideStringParam(branchParam, 'branch');
|
|
219
|
+
if (!branchResult.ok) {
|
|
220
|
+
return branchResult;
|
|
221
|
+
}
|
|
222
|
+
if (branchResult.value !== undefined) {
|
|
223
|
+
candidate.branch = branchResult.value;
|
|
224
|
+
}
|
|
225
|
+
// PRD #621 M2: the X-Dot-AI-Git-Token header (read by the handler and passed
|
|
226
|
+
// in already-normalized to a non-empty string or undefined) authenticates
|
|
227
|
+
// THIS override clone. It travels only as a header — never query/body — and
|
|
228
|
+
// is never echoed (computePromptsSource uses repoUrl only).
|
|
229
|
+
if (gitToken) {
|
|
230
|
+
candidate.gitToken = gitToken;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
// Throws on invalid scheme / subPath / branch.
|
|
234
|
+
(0, user_prompts_loader_1.getUserPromptsConfigFromOverride)(candidate);
|
|
235
|
+
return { ok: true, override: candidate };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const raw = error instanceof Error ? error.message : 'Invalid override';
|
|
239
|
+
return { ok: false, message: (0, git_utils_1.scrubCredentials)(raw) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
111
242
|
/**
|
|
112
243
|
* HTTP status codes for REST responses
|
|
113
244
|
*/
|
|
@@ -1086,53 +1217,17 @@ class RestApiRouter {
|
|
|
1086
1217
|
await this.sendErrorResponse(res, requestId, HttpStatus.INTERNAL_SERVER_ERROR, 'LOGS_ERROR', 'Failed to retrieve logs', { error: errorMessage });
|
|
1087
1218
|
}
|
|
1088
1219
|
}
|
|
1089
|
-
/**
|
|
1090
|
-
* Extract and validate the per-request `repo` override (PRD #581).
|
|
1091
|
-
*
|
|
1092
|
-
* Returns:
|
|
1093
|
-
* - { ok: true, override } when no repo param is supplied, override is undefined.
|
|
1094
|
-
* - { ok: true, override } when a syntactically valid override URL is supplied.
|
|
1095
|
-
* - { ok: false, message } when the override fails validation (HTTP 400).
|
|
1096
|
-
*
|
|
1097
|
-
* The validation message is run through scrubCredentials so embedded tokens
|
|
1098
|
-
* never reach the wire response.
|
|
1099
|
-
*/
|
|
1100
|
-
extractPromptsOverride(repoParam) {
|
|
1101
|
-
// Treat absent (null/undefined) as no override.
|
|
1102
|
-
if (repoParam === undefined || repoParam === null) {
|
|
1103
|
-
return { ok: true, override: undefined };
|
|
1104
|
-
}
|
|
1105
|
-
// The wire contract says `repo` is a string. Anything else (array,
|
|
1106
|
-
// number, object, boolean) is a 400 — otherwise downstream code
|
|
1107
|
-
// would crash to a 500.
|
|
1108
|
-
if (typeof repoParam !== 'string') {
|
|
1109
|
-
return {
|
|
1110
|
-
ok: false,
|
|
1111
|
-
message: `repo must be a string (got ${Array.isArray(repoParam) ? 'array' : typeof repoParam})`,
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
const trimmed = repoParam.trim();
|
|
1115
|
-
if (!trimmed) {
|
|
1116
|
-
return { ok: true, override: undefined };
|
|
1117
|
-
}
|
|
1118
|
-
const candidate = { repoUrl: trimmed };
|
|
1119
|
-
try {
|
|
1120
|
-
// Throws on invalid scheme / subPath / branch.
|
|
1121
|
-
(0, user_prompts_loader_1.getUserPromptsConfigFromOverride)(candidate);
|
|
1122
|
-
return { ok: true, override: candidate };
|
|
1123
|
-
}
|
|
1124
|
-
catch (error) {
|
|
1125
|
-
const raw = error instanceof Error ? error.message : 'Invalid override';
|
|
1126
|
-
return { ok: false, message: (0, git_utils_1.scrubCredentials)(raw) };
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
1220
|
/**
|
|
1130
1221
|
* Handle prompts list requests
|
|
1131
1222
|
*/
|
|
1132
1223
|
async handlePromptsListRequest(req, res, requestId, searchParams) {
|
|
1133
1224
|
try {
|
|
1134
1225
|
this.logger.info('Processing prompts list request', { requestId });
|
|
1135
|
-
|
|
1226
|
+
// PRD #581: ?repo= override. PRD #621 M1: ?path= / ?branch= thread into
|
|
1227
|
+
// candidate.subPath / candidate.branch (absent → unchanged behavior).
|
|
1228
|
+
// PRD #621 M2: X-Dot-AI-Git-Token header authenticates the override clone
|
|
1229
|
+
// (inert when no ?repo= override is present).
|
|
1230
|
+
const overrideResult = extractPromptsOverride(searchParams.get('repo'), searchParams.get('path'), searchParams.get('branch'), readGitTokenHeader(req));
|
|
1136
1231
|
if (!overrideResult.ok) {
|
|
1137
1232
|
await this.sendErrorResponse(res, requestId, HttpStatus.BAD_REQUEST, 'VALIDATION_ERROR', overrideResult.message);
|
|
1138
1233
|
return;
|
|
@@ -1169,7 +1264,11 @@ class RestApiRouter {
|
|
|
1169
1264
|
requestId,
|
|
1170
1265
|
promptName,
|
|
1171
1266
|
});
|
|
1172
|
-
|
|
1267
|
+
// PRD #581: ?repo= override. PRD #621 M1: ?path= / ?branch= thread into
|
|
1268
|
+
// candidate.subPath / candidate.branch (absent → unchanged behavior).
|
|
1269
|
+
// PRD #621 M2: X-Dot-AI-Git-Token header authenticates the override clone
|
|
1270
|
+
// (inert when no ?repo= override is present).
|
|
1271
|
+
const overrideResult = extractPromptsOverride(searchParams.get('repo'), searchParams.get('path'), searchParams.get('branch'), readGitTokenHeader(req));
|
|
1173
1272
|
if (!overrideResult.ok) {
|
|
1174
1273
|
await this.sendErrorResponse(res, requestId, HttpStatus.BAD_REQUEST, 'VALIDATION_ERROR', overrideResult.message);
|
|
1175
1274
|
return;
|
|
@@ -1214,9 +1313,13 @@ class RestApiRouter {
|
|
|
1214
1313
|
requestId,
|
|
1215
1314
|
});
|
|
1216
1315
|
// body.repo type is checked inside extractPromptsOverride (it accepts
|
|
1217
|
-
// unknown and rejects non-string values with 400 — see F2).
|
|
1316
|
+
// unknown and rejects non-string values with 400 — see F2). PRD #621 M1:
|
|
1317
|
+
// body `path` / `branch` thread into candidate.subPath / candidate.branch
|
|
1318
|
+
// (absent → unchanged behavior); both are likewise type-checked.
|
|
1218
1319
|
const bodyObj = body;
|
|
1219
|
-
|
|
1320
|
+
// PRD #621 M2: the credential always travels as the X-Dot-AI-Git-Token
|
|
1321
|
+
// header — never the body — and is inert without a repo override.
|
|
1322
|
+
const overrideResult = extractPromptsOverride(bodyObj?.repo, bodyObj?.path, bodyObj?.branch, readGitTokenHeader(req));
|
|
1220
1323
|
if (!overrideResult.ok) {
|
|
1221
1324
|
await this.sendErrorResponse(res, requestId, HttpStatus.BAD_REQUEST, 'VALIDATION_ERROR', overrideResult.message);
|
|
1222
1325
|
return;
|
|
@@ -1599,7 +1702,9 @@ class RestApiRouter {
|
|
|
1599
1702
|
};
|
|
1600
1703
|
if (this.config.enableCors) {
|
|
1601
1704
|
headers['Access-Control-Allow-Origin'] = '*';
|
|
1602
|
-
|
|
1705
|
+
// R-2: use the shared allowlist (single source of truth in cors-headers.ts)
|
|
1706
|
+
// so the SSE preflight includes X-Dot-AI-Git-Token like every other route.
|
|
1707
|
+
headers['Access-Control-Allow-Headers'] = cors_headers_1.REST_CORS_ALLOW_HEADERS;
|
|
1603
1708
|
}
|
|
1604
1709
|
res.writeHead(HttpStatus.OK, headers);
|
|
1605
1710
|
this.logger.info('SSE connection established', { requestId });
|
|
@@ -2187,7 +2292,9 @@ class RestApiRouter {
|
|
|
2187
2292
|
setCorsHeaders(res) {
|
|
2188
2293
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2189
2294
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
2190
|
-
|
|
2295
|
+
// PRD #621 M2 / Decision 1: advertise the X-Dot-AI-Git-Token credential
|
|
2296
|
+
// header (shared with the mcp.ts allowlist via cors-headers.ts).
|
|
2297
|
+
res.setHeader('Access-Control-Allow-Headers', cors_headers_1.REST_CORS_ALLOW_HEADERS);
|
|
2191
2298
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
2192
2299
|
}
|
|
2193
2300
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vfarcic/dot-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance",
|
|
5
5
|
"mcpName": "io.github.vfarcic/dot-ai",
|
|
6
6
|
"main": "dist/index.js",
|