feat(prd-621): per-request path, branch, and credential for the prompts repo override#633
Conversation
…epo override Extend the per-request prompts-repo override (PRD #581 / #607) with optional path, branch, and a git credential — all additive. Omitting all three is byte-identical to v1.21.0 (repo root, main branch, server env credential). - M1: GET /api/v1/prompts and POST /api/v1/prompts/:name accept ?path=/?branch=; POST /api/v1/prompts/refresh accepts path/branch body fields. Threaded into the override subPath/branch via the exported extractPromptsOverride. - M2: X-Dot-AI-Git-Token header read in the REST handlers and sourced into the override (header takes precedence over env, for that request only). Header added to both CORS allowlists via the new cors-headers single-source module. - M3: per-call clone token delivered via GIT_ASKPASS (token never on the git argv or in .git/config); scoped to the source host with http.followRedirects =false (no cross-host credential leak); per-request cache isolation so a token-bearing private clone never touches the shared cache, and the token is never part of the cache key. - M5: mock-server mirrors the new params and the header (fixtures + routes). - M6: REST API and prompts tool docs updated; changelog fragment added. - Security hardening: sensitive request headers redacted in debug logs; isolated clone dir created 0700 with a UUID name; clone errors scrubbed. Tests: build + lint + 543 unit tests green; integration parity guards and an end-to-end token-clone test added (verified at the CI integration gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ache tests
Failure A (production): the M3 token-bearing override clone was aborted by
simple-git's safety scanner ('-c credential.helper=' / GIT_ASKPASS guards)
before git ran, so the clone produced an empty repo. Spawn git directly via
child_process for the token path (env path unchanged), keep the token off the
argv and out of .git/config (username-only URL + token via env), and make
GIT_ASKPASS host-bound (token released only for the intended source host).
Dropped http.followRedirects=false (review R-1) since the host-bound askpass
plus libcurl's default cross-host credential stripping preserve Decision 3
while allowing legitimate same-host redirects.
Failure B (test): the prompts integration suite ran describe.concurrent, so
non-token override happy-path clones mutated the shared loader cache while
cache-dependent tests read it (expected 11 to be 15). Serialized the file
(describe.concurrent -> describe); no assertions changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds optional per-request ChangesPer-request prompts override with path, branch, and credential
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (2)
src/core/git-utils.ts (1)
322-387: 💤 Low valueThe timeout/close/error event handling could race in edge cases.
If
spawnitself fails (e.g.,gitnot found), the'error'event fires and rejects. Later,'close'may also fire and attempt to resolve/reject again. Since a Promise can only settle once, this is functionally safe, but the second callback executes unnecessary logic.This is low priority since the code is correct, but a settled flag would be slightly cleaner.
♻️ Optional: track settlement to avoid redundant callback execution
try { await new Promise<void>((resolve, reject) => { + let settled = false; const child = spawn('git', args, { env, stdio: ['ignore', 'ignore', 'pipe'], }); let stderr = ''; const timer = setTimeout(() => { child.kill('SIGKILL'); + if (!settled) { + settled = true; reject(new Error(`git clone timed out after ${GIT_TIMEOUT_MS}ms`)); + } }, GIT_TIMEOUT_MS); child.stderr?.on('data', chunk => { stderr += chunk.toString(); }); child.on('error', err => { clearTimeout(timer); + if (!settled) { + settled = true; reject(err); + } }); child.on('close', code => { clearTimeout(timer); + if (settled) return; + settled = true; if (code === 0) { resolve(); } else { reject( new Error(`git clone exited with code ${code}: ${stderr.trim()}`) ); } }); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/git-utils.ts` around lines 322 - 387, The cloneWithOverrideToken promise can run its 'error' and 'close' handlers both and perform duplicate work; add a local settled boolean inside the new Promise in cloneWithOverrideToken and check it at the start of each callback (child.on('error'), child.on('close'), and the timeout) so you only clear the timer and call resolve()/reject() once; set settled = true immediately before calling resolve()/reject() and ensure the timeout handler also checks/set this flag to avoid redundant logic while keeping askpass.cleanup() in the existing finally block.changelog.d/621.feature.md (1)
1-3: ⚡ Quick winRename the changelog fragment to a descriptive Towncrier slug.
621.feature.mdhas the right type suffix, but the description part should identify the change so release tooling and readers can distinguish it. As per coding guidelines, changelog fragments must use<description>.<type>.md.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@changelog.d/621.feature.md` around lines 1 - 3, The changelog fragment file currently named "621.feature.md" should be renamed to a descriptive Towncrier slug using the pattern <description>.<type>.md; e.g., rename "621.feature.md" to something like "per-request-prompts-repo-path-branch-credential.feature.md" so the filename identifies the change; update any references if needed and ensure the file retains the same content and the ".feature.md" suffix.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/ai-engine/api/rest-api.md`:
- Around line 379-388: The curl examples that use the header X-Dot-AI-Git-Token
with a placeholder (<token for that repo>) in the REST API docs are not runnable
and lack validated response output; either replace each placeholder-only example
(the subdirectory+branch+per-request-credential curl blocks that include
X-Dot-AI-Git-Token and the corresponding response snippets) with concrete,
scrubbed/tested values plus the real response body you observed when testing, or
clearly mark those snippets as illustrative/non-runnable; update all occurrences
referenced in the comment (the example blocks and their response outputs) so the
documented commands and responses are consistent and validated.
In `@mock-server/server.ts`:
- Around line 164-174: The refresh endpoint handles overrides incorrectly: for
POST /api/v1/prompts/refresh the code currently seeds repo/path/branch from
query params first and only then falls back to the JSON body; change the logic
so that when method === 'POST' and route.path === '/api/v1/prompts/refresh' you
read the body via readJsonBody(req) and prefer body values for repo/path/branch
(using coerceOverrideParam on body fields) and only use query params as
fallbacks, updating variables repo, pathParam, and branchParam accordingly.
In `@src/interfaces/cors-headers.ts`:
- Line 39: MCP_CORS_ALLOW_HEADERS currently lists X-Session-Id but the runtime
uses the header name mcp-session-id (see mcp-session-id referenced in
src/interfaces/mcp.ts), causing CORS preflight failures; update the
MCP_CORS_ALLOW_HEADERS constant to include "mcp-session-id" (in addition to or
instead of X-Session-Id) so browser preflight requests allow the actual header
used by the session routing logic (ensure the exact token string mcp-session-id
is added to the template string that builds MCP_CORS_ALLOW_HEADERS).
In `@src/interfaces/mcp.ts`:
- Around line 659-663: The debug log call on this.logger.debug exposes sensitive
credentials because it logs req.url verbatim; instead sanitize the URL before
logging by parsing req.url (use the same redaction logic as
redactSensitiveHeaders or a new helper) and removing credentials from the
authority and any sensitive query params (e.g., strip user:pass@ from host and
redact repo/token query values), then pass the sanitized value in place of
req.url in the existing this.logger.debug call (refer to this.logger.debug,
redactSensitiveHeaders, and req.url to locate and update the code).
In `@tests/integration/tools/prompts.test.ts`:
- Around line 750-1158: Across the PRD `#621` integration tests (notably the tests
using expectEventually, restoreEnvCache and the named tests like "GET
/api/v1/prompts?path=&branch=", the token-clone e2e, refresh/validation, CORS
preflight and parity tests), replace standalone Jest matchers (.toBe(true),
.toBe(...), .toContain(...), .not.toContain(...), .toEqual(...)) with the repo
pattern of using expect(...).toMatchObject({ ... }) for assertions; where you
need substring/array checks, embed expect.stringContaining(...) or
expect.arrayContaining(...) inside the toMatchObject payload (e.g., assert
headers/source/prompts via data: { source: expect.stringContaining(...) } or
prompts: expect.arrayContaining([...]) ) and ensure negative contains checks are
expressed by asserting the serialized response does not include the secret via
expect(JSON.stringify(response)).not.toMatchObject is not valid — instead keep
the existing explicit JSON.stringify(...).not.toContain(secret) checks but wrap
them into toMatchObject-driven positive assertions where possible, updating each
occurrence referenced in the comment to follow the consistent
toMatchObject-first style.
- Around line 758-783: The test can leak the throwaway branch when file creation
fails because fixtureReady is only set after the PUT succeeds; change the
teardown logic to track branch creation separately and always attempt cleanup:
introduce a boolean like branchCreated (set true after branchRes.ok / after
creating the ref via ghApi) and in afterAll delete the branch if branchCreated
is true (and still call restoreEnvCache regardless); update references in the
test to use branchCreated alongside or instead of fixtureReady so the branch is
removed even on partial setup failures.
- Line 812: Update the comprehensive workflow integration test timeouts in
tests/integration/tools/prompts.test.ts by replacing all occurrences of timeout
literals 120000 and 180000 with 300000; specifically change test declarations
that end with "}, 120000);" and "}, 180000);" (and any it(...) or test(...)
calls using those numeric timeout args) so every comprehensive workflow test in
that file uses 300000ms.
- Line 27: Switch the top-level suite to parallel by replacing describe('Prompts
Integration', ...) with describe.concurrent(...) and make matchers consistent by
replacing mixed uses of .toBe/.toEqual/.toContain with toMatchObject where
structured comparisons are expected (search for occurrences in this file),
update all long-running test timeouts to the required 300000ms (replace existing
120000/180000ms values and the noted ~812..~1157ms instances), and fix the M1
fixture lifecycle by removing the afterAll guard that skips cleanup when
fixtureReady is false—instead track whether the branch/resource was actually
created (e.g., set a createdBranch flag in beforeAll) or use try/finally so
afterAll always attempts cleanup regardless of fixtureReady; update references
to fixtureReady, beforeAll, and afterAll accordingly so partial setup failures
cannot skip teardown.
---
Nitpick comments:
In `@changelog.d/621.feature.md`:
- Around line 1-3: The changelog fragment file currently named "621.feature.md"
should be renamed to a descriptive Towncrier slug using the pattern
<description>.<type>.md; e.g., rename "621.feature.md" to something like
"per-request-prompts-repo-path-branch-credential.feature.md" so the filename
identifies the change; update any references if needed and ensure the file
retains the same content and the ".feature.md" suffix.
In `@src/core/git-utils.ts`:
- Around line 322-387: The cloneWithOverrideToken promise can run its 'error'
and 'close' handlers both and perform duplicate work; add a local settled
boolean inside the new Promise in cloneWithOverrideToken and check it at the
start of each callback (child.on('error'), child.on('close'), and the timeout)
so you only clear the timer and call resolve()/reject() once; set settled = true
immediately before calling resolve()/reject() and ensure the timeout handler
also checks/set this flag to avoid redundant logic while keeping
askpass.cleanup() in the existing finally block.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 1e894a84-4712-40a8-8fd6-a1b36be2692b
📒 Files selected for processing (21)
changelog.d/621.feature.mddocs/ai-engine/api/rest-api.mddocs/ai-engine/tools/prompts.mdmock-server/fixtures/prompts/get-override-path-branch.jsonmock-server/fixtures/prompts/list-override-path-branch.jsonmock-server/fixtures/prompts/refresh-override-path-branch.jsonmock-server/prompts-override.tsmock-server/server.tsprds/621-prompts-override-path-branch-credential.mdsrc/core/git-utils.tssrc/core/user-prompts-loader.tssrc/interfaces/cors-headers.tssrc/interfaces/header-redaction.tssrc/interfaces/mcp.tssrc/interfaces/rest-api.tstests/integration/tools/prompts.test.tstests/unit/core/git-utils-override-auth.test.tstests/unit/core/user-prompts-loader.test.tstests/unit/interfaces/cors-headers.test.tstests/unit/interfaces/header-redaction.test.tstests/unit/interfaces/rest-api-prompts-override.test.ts
| beforeAll(async () => { | ||
| if (!gitToken) return; | ||
|
|
||
| // 1. Resolve main's commit SHA. | ||
| const refRes = await ghApi('GET', '/git/ref/heads/main'); | ||
| expect(refRes.ok).toBe(true); | ||
| const refData = (await refRes.json()) as { object: { sha: string } }; | ||
|
|
||
| // 2. Create the throwaway fixture branch off main. | ||
| const branchRes = await ghApi('POST', '/git/refs', { | ||
| ref: `refs/heads/${fixtureBranch}`, | ||
| sha: refData.object.sha, | ||
| }); | ||
| expect(branchRes.ok).toBe(true); | ||
|
|
||
| // 3. Commit the fixture prompt under the non-default subdirectory on it. | ||
| const fileRes = await ghApi('PUT', `/contents/${fixturePromptPath}`, { | ||
| message: 'test: add PRD #621 path/branch override fixture', | ||
| content: Buffer.from(fixturePromptContent).toString('base64'), | ||
| branch: fixtureBranch, | ||
| }); | ||
| expect(fileRes.ok).toBe(true); | ||
|
|
||
| fixtureReady = true; | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| if (!fixtureReady) return; | ||
| // Deleting the branch removes the fixture file with it. | ||
| await ghApi('DELETE', `/git/refs/heads/${fixtureBranch}`); | ||
| // Leave the server cache on the env-var coordinate for other test files. | ||
| await restoreEnvCache(); | ||
| }); | ||
|
|
||
| test('GET /api/v1/prompts?path=&branch= resolves a prompt from the subdir on the non-default branch (PRD #621)', async () => { | ||
| if (!gitToken) { | ||
| console.log( | ||
| 'Skipping PRD #621 path/branch GET test: DOT_AI_GIT_TOKEN not set' | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| await expectEventually(async () => { | ||
| const response = await integrationTest.httpClient.get( | ||
| `/api/v1/prompts?repo=${encodeURIComponent(promptsRepoUrl)}` + | ||
| `&path=${encodeURIComponent(fixtureSubdir)}` + | ||
| `&branch=${encodeURIComponent(fixtureBranch)}` | ||
| ); | ||
| expect(response).toMatchObject({ success: true }); | ||
| const fixturePrompt = response.data.prompts.find( | ||
| (p: { name: string }) => p.name === fixturePromptName | ||
| ); | ||
| // RED until M1: ?path=/?branch= are dropped, the clone reads repo | ||
| // ROOT on MAIN, and this prompt is absent (fixturePrompt undefined). | ||
| expect(fixturePrompt).toMatchObject({ | ||
| name: fixturePromptName, | ||
| description: fixtureDescription, | ||
| }); | ||
| }); | ||
| } finally { | ||
| await restoreEnvCache(); | ||
| } | ||
| }, 120000); | ||
|
|
||
| // End-to-end exercise of the real git clone-with-token path (PRD #621 M3). | ||
| // This is the ONLY e2e coverage of the credential clone: every other | ||
| // positive token test mocks simple-git. The token-vs-env auth distinction | ||
| // is deliberately NOT asserted (no private/second-realm repo is available in | ||
| // CI), so this does NOT prove "the token authenticated where env would not". | ||
| // Instead it sends the credential header alongside ?path=/?branch= so M2 | ||
| // reads it and M3 threads it into an ISOLATED, per-request token-bearing | ||
| // clone (GIT_ASKPASS keeps the token off the URL/argv/.git/config; | ||
| // http.followRedirects is scoped to the source host) — then asserts the | ||
| // distinct subdir@branch prompt still resolves and the token never leaks. | ||
| // The point: prove the credential mechanism (GIT_ASKPASS / redirect config) | ||
| // does NOT break cloning the public fixture, end to end. It would catch a | ||
| // regression where, e.g., followRedirects=false blocks git's normal | ||
| // github.com -> codeload redirect and the clone silently yields no prompts. | ||
| test('GET /api/v1/prompts?path=&branch= with X-Dot-AI-Git-Token clones the subdir/branch prompt end-to-end without leaking the token (PRD #621 M3)', async () => { | ||
| if (!gitToken) { | ||
| console.log( | ||
| 'Skipping PRD #621 M3 token-clone e2e test: DOT_AI_GIT_TOKEN not set' | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| await expectEventually(async () => { | ||
| const response = await integrationTest.httpClient.get( | ||
| `/api/v1/prompts?repo=${encodeURIComponent(promptsRepoUrl)}` + | ||
| `&path=${encodeURIComponent(fixtureSubdir)}` + | ||
| `&branch=${encodeURIComponent(fixtureBranch)}`, | ||
| // Real env token: guarantees the public clone succeeds (so a failure | ||
| // means the credential MECHANISM broke the clone, not bad auth). | ||
| { 'X-Dot-AI-Git-Token': gitToken } | ||
| ); | ||
| expect(response).toMatchObject({ success: true }); | ||
| // The distinct subdir@branch prompt resolves through the token-bearing | ||
| // isolated clone. | ||
| const fixturePrompt = response.data.prompts.find( | ||
| (p: { name: string }) => p.name === fixturePromptName | ||
| ); | ||
| expect(fixturePrompt).toMatchObject({ | ||
| name: fixturePromptName, | ||
| description: fixtureDescription, | ||
| }); | ||
| // The forwarded credential must never appear in the response surface | ||
| // (source, error, or anywhere). | ||
| expect(JSON.stringify(response)).not.toContain(gitToken); | ||
| }); | ||
| } finally { | ||
| await restoreEnvCache(); | ||
| } | ||
| }, 120000); | ||
|
|
||
| test('POST /api/v1/prompts/:name?path=&branch= returns the subdir/branch prompt content (PRD #621)', async () => { | ||
| if (!gitToken) { | ||
| console.log( | ||
| 'Skipping PRD #621 path/branch get-by-name test: DOT_AI_GIT_TOKEN not set' | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| await expectEventually(async () => { | ||
| const response = await integrationTest.httpClient.post( | ||
| `/api/v1/prompts/${fixturePromptName}` + | ||
| `?repo=${encodeURIComponent(promptsRepoUrl)}` + | ||
| `&path=${encodeURIComponent(fixtureSubdir)}` + | ||
| `&branch=${encodeURIComponent(fixtureBranch)}`, | ||
| {} | ||
| ); | ||
| // RED until M1: path/branch dropped → prompt not found → 400. | ||
| expect(response).toMatchObject({ | ||
| success: true, | ||
| data: { | ||
| description: fixtureDescription, | ||
| messages: [ | ||
| { | ||
| role: 'user', | ||
| content: { type: 'text', text: expect.any(String) }, | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
| }); | ||
| } finally { | ||
| await restoreEnvCache(); | ||
| } | ||
| }, 120000); | ||
|
|
||
| test('POST /api/v1/prompts/refresh honors path and branch body fields (PRD #621)', async () => { | ||
| if (!gitToken) { | ||
| console.log( | ||
| 'Skipping PRD #621 refresh body path/branch test: DOT_AI_GIT_TOKEN not set' | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| await expectEventually(async () => { | ||
| // Control: same branch, a subdirectory that does NOT exist on it → | ||
| // built-in prompts only (no user prompts loaded). | ||
| const controlRes = await integrationTest.httpClient.post( | ||
| '/api/v1/prompts/refresh', | ||
| { | ||
| repo: promptsRepoUrl, | ||
| path: `${fixtureSubdir}-absent-${overrideRunId}`, | ||
| branch: fixtureBranch, | ||
| } | ||
| ); | ||
| expect(controlRes).toMatchObject({ | ||
| success: true, | ||
| data: { refreshed: true, promptsLoaded: expect.any(Number) }, | ||
| }); | ||
|
|
||
| // Fixture: the subdirectory that DOES exist on the branch → | ||
| // built-in prompts + exactly the one fixture prompt. | ||
| const fixtureRes = await integrationTest.httpClient.post( | ||
| '/api/v1/prompts/refresh', | ||
| { | ||
| repo: promptsRepoUrl, | ||
| path: fixtureSubdir, | ||
| branch: fixtureBranch, | ||
| } | ||
| ); | ||
| expect(fixtureRes).toMatchObject({ | ||
| success: true, | ||
| data: { | ||
| refreshed: true, | ||
| promptsLoaded: expect.any(Number), | ||
| source: expect.stringContaining('dot-ai-test-prompts'), | ||
| }, | ||
| }); | ||
|
|
||
| // RED until M1: body path/branch are dropped, so BOTH refreshes | ||
| // clone repo ROOT on MAIN and load the same count → the +1 from the | ||
| // fixture subdir never appears. | ||
| expect(fixtureRes.data.promptsLoaded).toBe( | ||
| controlRes.data.promptsLoaded + 1 | ||
| ); | ||
| }); | ||
| } finally { | ||
| await restoreEnvCache(); | ||
| } | ||
| }, 180000); | ||
|
|
||
| test('GET /api/v1/prompts?path=<traversal> returns 400 with scrubbed credentials and leaves the env cache intact (PRD #621)', async () => { | ||
| const secret = 'prd621_path_secret_xyz'; | ||
| const credUrl = `https://user:${secret}@github.com/vfarcic/dot-ai-test-prompts.git`; | ||
|
|
||
| const before = await integrationTest.httpClient.get('/api/v1/prompts'); | ||
| expect(before).toMatchObject({ success: true }); | ||
|
|
||
| // RED until M1: ?path= is dropped, the override validates on repoUrl | ||
| // alone, and the traversal subPath is never rejected (no 400). | ||
| const response = await integrationTest.httpClient.get( | ||
| `/api/v1/prompts?repo=${encodeURIComponent(credUrl)}` + | ||
| `&path=${encodeURIComponent('../../etc/passwd')}` | ||
| ); | ||
| expect(response).toMatchObject({ | ||
| success: false, | ||
| error: { code: 'VALIDATION_ERROR' }, | ||
| }); | ||
| // The embedded credential must never reach the response. | ||
| expect(JSON.stringify(response)).not.toContain(secret); | ||
|
|
||
| // A request rejected BEFORE any clone must not corrupt the env-var cache: | ||
| // the env config still serves the same prompts and source afterwards. | ||
| const after = await integrationTest.httpClient.get('/api/v1/prompts'); | ||
| expect(after).toMatchObject({ | ||
| success: true, | ||
| data: { source: before.data.source }, | ||
| }); | ||
| expect(after.data.prompts.length).toBe(before.data.prompts.length); | ||
| }, 120000); | ||
|
|
||
| test('POST /api/v1/prompts/refresh with invalid branch returns 400 with scrubbed credentials (PRD #621)', async () => { | ||
| const secret = 'prd621_branch_secret_xyz'; | ||
| const credUrl = `https://user:${secret}@github.com/vfarcic/dot-ai-test-prompts.git`; | ||
|
|
||
| // RED until M1: body `branch` is dropped, so the illegal branch name is | ||
| // never validated and no 400 is returned. | ||
| const response = await integrationTest.httpClient.post( | ||
| '/api/v1/prompts/refresh', | ||
| { repo: credUrl, branch: 'bad branch name!!' } | ||
| ); | ||
| expect(response).toMatchObject({ | ||
| success: false, | ||
| error: { code: 'VALIDATION_ERROR' }, | ||
| }); | ||
| expect(JSON.stringify(response)).not.toContain(secret); | ||
| }, 120000); | ||
| }); | ||
|
|
||
| // PRD #621 M2/M3/M4: the override gains an optional per-request git CREDENTIAL | ||
| // forwarded via the `X-Dot-AI-Git-Token` request header (M2 reads it + adds it | ||
| // to both CORS allowlists; M3 uses it for the clone, scoped to the source host, | ||
| // with cache isolation). M4 is the non-negotiable backward-compat parity guard. | ||
| // | ||
| // What IS robustly observable via black-box HTTP — and therefore lives here: | ||
| // - M2 CORS allowlist: an OPTIONS preflight advertises the new header | ||
| // (RED today — neither allowlist lists it yet). | ||
| // - M4 parity: a request with NO path/branch/header behaves like v1.21.0, | ||
| // for both the no-?repo= (env-var) path and the ?repo=-only path, and the | ||
| // credential header is never echoed back. NOTE: the header is truly inert | ||
| // ONLY on the no-?repo= path. When a ?repo= override IS present the token | ||
| // is NOT inert — M2 reads it and M3 threads it into an isolated, | ||
| // per-request token-bearing clone (GIT_ASKPASS) that bypasses the shared | ||
| // cache. It does not change the observed prompt set/source in the | ||
| // ?repo=-only test only because the asserted prompt is built-in and the | ||
| // public test-repo root carries no user prompts. | ||
| // | ||
| // What is NOT robustly observable here (flagged for the coder as UNIT tests — | ||
| // see the report; the #581 happy-path override coverage was pushed to unit | ||
| // tests for the same shared-server/shared-cache reasons): | ||
| // - Credential PRECEDENCE / AUTH (override.gitToken ?? env; clone auth | ||
| // against the source host): needs a private or second-auth-realm repo — | ||
| // a positive auth test is non-distinguishing because the env token can | ||
| // already read the env realm, and the only RED-distinguishing signal | ||
| // (auth failure => content ABSENT) is retry-unsafe and race-unsafe on the | ||
| // shared deployed server + single non-coordinate-keyed cache directory. | ||
| // - CACHE ISOLATION (token-bearing private clone not served to/from the | ||
| // shared unauthenticated slot; token absent from the cache key): the | ||
| // cross-serve window is unobservable/flaky black-box; unit tests can | ||
| // control cache state precisely (git boundary mocked). | ||
| // - CROSS-HOST REDIRECT non-forwarding (decision 3): needs a controllable | ||
| // redirecting git host — not available; unit-test the auth/redirect path. | ||
| // - LOG scrubbing (token absent from server logs): logs are not HTTP-observable. | ||
| describe('Per-request credential header + backward-compat parity (PRD #621 M2/M3/M4)', () => { | ||
| const promptsRepoUrl = | ||
| 'https://github.com/vfarcic/dot-ai-test-prompts.git'; | ||
|
|
||
| // Retry an equality/presence assertion to absorb a transient concurrent | ||
| // re-clone of the shared cache directory (see the M1 cache/race note). Used | ||
| // only for assertions whose GREEN state is the STABLE state, so retrying can | ||
| // absorb a race but cannot mask a genuine M2/M3 regression (which would fail | ||
| // every attempt). | ||
| async function expectEventually( | ||
| fn: () => Promise<void>, | ||
| attempts = 5, | ||
| delayMs = 1500 | ||
| ): Promise<void> { | ||
| let lastError: unknown; | ||
| for (let i = 0; i < attempts; i++) { | ||
| try { | ||
| await fn(); | ||
| return; | ||
| } catch (error) { | ||
| lastError = error; | ||
| await new Promise(resolve => setTimeout(resolve, delayMs)); | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
|
|
||
| async function restoreEnvCache(): Promise<void> { | ||
| await integrationTest.httpClient.post('/api/v1/prompts/refresh', {}); | ||
| } | ||
|
|
||
| // ---- M2: CORS allowlist (the one genuinely RED-today test in this block) ---- | ||
| test('OPTIONS /api/v1/prompts preflight advertises the X-Dot-AI-Git-Token header (PRD #621 M2)', async () => { | ||
| const baseUrl = process.env.MCP_BASE_URL || 'http://localhost:3456'; | ||
| // OPTIONS is answered (204) before auth and carries the CORS allowlist. | ||
| const res = await fetch(`${baseUrl}/api/v1/prompts`, { | ||
| method: 'OPTIONS', | ||
| headers: { | ||
| Origin: 'http://localhost', | ||
| 'Access-Control-Request-Method': 'GET', | ||
| 'Access-Control-Request-Headers': 'x-dot-ai-git-token', | ||
| }, | ||
| }); | ||
| const allowHeaders = res.headers.get('access-control-allow-headers'); | ||
| // Sanity: CORS is enabled and surfaced through the ingress. | ||
| expect(allowHeaders).toBeTruthy(); | ||
| // RED until M2: the allowlist is "Content-Type, X-Session-Id, | ||
| // Authorization, X-Dot-AI-Authorization" (mcp.ts) / "Content-Type, | ||
| // Authorization" (rest-api.ts) — neither lists the new credential header. | ||
| expect((allowHeaders || '').toLowerCase()).toContain( | ||
| 'x-dot-ai-git-token' | ||
| ); | ||
| }, 60000); | ||
|
|
||
| // ---- M4 parity: no-?repo= (env-var) path is inert to the credential header ---- | ||
| test('no-override request behaves like v1.21.0 with the credential header present and never echoes it (PRD #621 M4 parity)', async () => { | ||
| const secret = 'prd621_norepo_header_secret_zzz'; | ||
| await expectEventually(async () => { | ||
| const withHeader = await integrationTest.httpClient.get( | ||
| '/api/v1/prompts', | ||
| { 'X-Dot-AI-Git-Token': secret } | ||
| ); | ||
| expect(withHeader).toMatchObject({ | ||
| success: true, | ||
| // Source is the env-var repo, unchanged by the header. | ||
| data: { source: expect.stringContaining('dot-ai-test-prompts') }, | ||
| }); | ||
| const names = withHeader.data.prompts.map( | ||
| (p: { name: string }) => p.name | ||
| ); | ||
| // Built-in AND env user prompts (loaded from the user-prompts/ subdir) | ||
| // are present => the env-var path is fully unaffected by the header. | ||
| expect(names).toContain('prd-create'); | ||
| expect(names).toContain('eval-run'); | ||
| // The forwarded credential must never appear in the response surface. | ||
| expect(JSON.stringify(withHeader)).not.toContain(secret); | ||
| }); | ||
| }, 120000); | ||
|
|
||
| // ---- M4 parity: ?repo=-only path matches PRD #581; the header is threaded | ||
| // into an isolated token clone but the observed outcome is unchanged | ||
| // here (built-in prompt + empty public repo root) ---- | ||
| test('?repo=-only request matches PRD #581 (root clone) with and without the credential header (PRD #621 M4 parity)', async () => { | ||
| const secret = 'prd621_repoonly_header_secret_zzz'; | ||
| try { | ||
| await expectEventually(async () => { | ||
| const res = await integrationTest.httpClient.get( | ||
| `/api/v1/prompts?repo=${encodeURIComponent(promptsRepoUrl)}` | ||
| ); | ||
| const resH = await integrationTest.httpClient.get( | ||
| `/api/v1/prompts?repo=${encodeURIComponent(promptsRepoUrl)}`, | ||
| { 'X-Dot-AI-Git-Token': secret } | ||
| ); | ||
| for (const r of [res, resH]) { | ||
| expect(r).toMatchObject({ | ||
| success: true, | ||
| data: { source: expect.stringContaining('dot-ai-test-prompts') }, | ||
| }); | ||
| const names = r.data.prompts.map((p: { name: string }) => p.name); | ||
| // ?repo=-only clones the repo ROOT on main (PRD #581 behavior): | ||
| // built-in prompts present, but env user prompts (which live under | ||
| // the user-prompts/ subdir) are NOT loaded. | ||
| expect(names).toContain('prd-create'); | ||
| expect(names).not.toContain('eval-run'); | ||
| } | ||
| // The header is NOT inert when a repo override is present: M2 reads it | ||
| // and M3 threads it into an isolated, per-request token-bearing clone. | ||
| // The prompt SET is identical here only because the asserted prompt is | ||
| // built-in and the public repo ROOT has no user prompts — so the | ||
| // token-bearing isolated clone and the unauthenticated clone surface | ||
| // the same result. This remains a valid parity guard (same observable | ||
| // result with or without the header). | ||
| const nameSet = (r: { data: { prompts: { name: string }[] } }) => | ||
| r.data.prompts.map(p => p.name).sort(); | ||
| expect(nameSet(resH)).toEqual(nameSet(res)); | ||
| // No credential leak in the response. | ||
| expect(JSON.stringify(resH)).not.toContain(secret); | ||
| }); | ||
| } finally { | ||
| await restoreEnvCache(); | ||
| } | ||
| }, 180000); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="tests/integration/tools/prompts.test.ts"
echo "== File: $FILE =="
wc -l "$FILE"
echo
echo "== Extract lines 730-1160 (with numbers) =="
nl -ba "$FILE" | sed -n '730,1160p'
echo
echo "== Matcher-style occurrences in file =="
rg -n "\.toBe\(|\.toEqual\(|\.toContain\(|not\.toContain\(" "$FILE" || true
echo
echo "== Matcher-style occurrences within 730-1160 =="
nl -ba "$FILE" | sed -n '730,1160p' | rg -n "\.toBe\(|\.toEqual\(|\.toContain\(|not\.toContain\(" || trueRepository: vfarcic/dot-ai
Length of output: 262
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="tests/integration/tools/prompts.test.ts"
echo "== File: $FILE =="
wc -l "$FILE"
echo
echo "== Extract lines 730-1160 (with numbers) =="
cat -n "$FILE" | sed -n '730,1160p'
echo
echo "== Matcher-style occurrences in file =="
rg -n "\.toBe\(|\.toEqual\(|\.toContain\(|not\.toContain\(" "$FILE" || true
echo
echo "== Matcher-style occurrences within 730-1160 =="
cat -n "$FILE" | sed -n '730,1160p' | rg -n "\.toBe\(|\.toEqual\(|\.toContain\(|not\.toContain\(" || trueRepository: vfarcic/dot-ai
Length of output: 25450
Fix tests/integration/tools/prompts.test.ts to use toMatchObject consistently (remove standalone .toBe()/.toContain()/.toEqual())
The PRD #621 integration block mixes assertion styles by using standalone matchers like .toBe(true) / .toBe(...) / .toContain(...) / .not.toContain(...) / .toEqual(...) (e.g., around lines 755, 763, 771, 945–947, 1085–1087, 1108–1111, 1138–1140, 1150–1152). Refactor these to the repo’s toMatchObject-first pattern (keeping expect.stringContaining/expect.arrayContaining inside toMatchObject where applicable).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/integration/tools/prompts.test.ts` around lines 750 - 1158, Across the
PRD `#621` integration tests (notably the tests using expectEventually,
restoreEnvCache and the named tests like "GET /api/v1/prompts?path=&branch=",
the token-clone e2e, refresh/validation, CORS preflight and parity tests),
replace standalone Jest matchers (.toBe(true), .toBe(...), .toContain(...),
.not.toContain(...), .toEqual(...)) with the repo pattern of using
expect(...).toMatchObject({ ... }) for assertions; where you need
substring/array checks, embed expect.stringContaining(...) or
expect.arrayContaining(...) inside the toMatchObject payload (e.g., assert
headers/source/prompts via data: { source: expect.stringContaining(...) } or
prompts: expect.arrayContaining([...]) ) and ensure negative contains checks are
expressed by asserting the serialized response does not include the secret via
expect(JSON.stringify(response)).not.toMatchObject is not valid — instead keep
the existing explicit JSON.stringify(...).not.toContain(secret) checks but wrap
them into toMatchObject-driven positive assertions where possible, updating each
occurrence referenced in the comment to follow the consistent
toMatchObject-first style.
Source: Coding guidelines
- mcp.ts: scrub req.url in the request debug log via sanitizeRequestUrlForLogging (credential-bearing ?repo=https://user:token@... no longer leaks to logs). - cors-headers.ts: add Mcp-Session-Id to the MCP CORS allowlist (session routing reads mcp-session-id; preflight could otherwise fail). Additive. - mock-server: POST /api/v1/prompts/refresh now reads repo/path/branch from the JSON body only (query ignored), matching the real server contract. - docs: mark the per-request-credential curl examples as illustrative/non-runnable. - tests(integration): fixture teardown always deletes the throwaway branch on partial setup failure (try/finally + createdBranch flag); align e2e timeouts to 300000ms; matcher-consistency comments. Suite stays serialized (shared cache). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ath-branch-and-credential-for-the-pro
Address CodeRabbit nitpick: the error/close/timeout handlers in cloneWithOverrideToken could each run logic on an already-settled promise. Add a settled-guard + timer cleanup so the first settling wins and later handlers no-op; behavior otherwise unchanged. Unit test covers error-then-close. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew checks Lean release prompt (defers mechanics to /prd-done): after opening the PR it waits for CI + automated reviews, reports back, and stops before merge — merging only on explicit re-delegation. Orchestrator: PR CI is the integration gate (dropped the redundant pre-PR tester run), changelog ownership moved into /prd-done, added the report-back -> route-fixes -> merge-on-go-ahead loop. Mirrors the proven vfarcic/dot-agent-deck setup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.dot-agent-deck.toml:
- Around line 33-35: The review flags a conflict where the "Final
integration-test gate" (the orchestrator-owned PR CI full-suite run) is
duplicated by the tester prompt that still instructs running the full suite;
update the tester prompt text so it no longer triggers the full `npm run
test:integration` run and instead either waits for the orchestrator's PR CI
result or runs a lightweight smoke check (or reports only), ensuring the tester
prompt references the orchestrator's final-gate ownership and explicitly defers
to the PR CI outcome; edit the tester prompt block (the prompt around Line 93)
and any mentions of "final gate" or "run the full suite" to reflect this
delegation to avoid duplicate long-running integration runs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: fb5ccbf4-8233-425b-9dfd-91a82bbf2971
📒 Files selected for processing (1)
.dot-agent-deck.toml
| - Final integration-test gate: the full `npm run test:integration` suite runs as the PR's CI (it spins up a Kind cluster; CLAUDE.md makes it mandatory before any work is complete). It runs once, on the PR — not per task during TDD, and not as a separate pre-PR run. The release worker watches that CI and reports the result back. | ||
| - Pre-release: summarize what changed end-to-end and STOP until the user confirms. Then delegate /prd-done to release. Release opens the PR, waits for the PR's CI and automated reviews (e.g. CodeRabbit) to settle, and reports back — it does NOT merge. Route any CI failures or review findings it reports to coder (implementation-side) or tester (test-side), then re-engage release to re-check. | ||
| - Merge: only on the user's explicit go-ahead do you re-delegate to release to finish /prd-done (merge the PR, close the issue). Never merge without that go-ahead. |
There was a problem hiding this comment.
Conflicting final-gate ownership between orchestrator and tester prompts
Line 33 defines PR CI as the only final full-suite gate, but the tester prompt (Line 93) still says to run the full suite when delegated the final gate. This creates ambiguous routing and can trigger duplicate long-running integration runs.
Suggested alignment patch
- Full suite (gate only): run the entire `npm run test:integration` (no pattern) ONLY when the orchestrator delegates the final integration-test gate before release — not on every task.
+ Full suite (gate ownership): do NOT run the entire `npm run test:integration` manually as a release gate. The final full-suite gate is the PR CI run watched by the release role. During TDD, run only scoped patterns delegated by the orchestrator.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.dot-agent-deck.toml around lines 33 - 35, The review flags a conflict where
the "Final integration-test gate" (the orchestrator-owned PR CI full-suite run)
is duplicated by the tester prompt that still instructs running the full suite;
update the tester prompt text so it no longer triggers the full `npm run
test:integration` run and instead either waits for the orchestrator's PR CI
result or runs a lightweight smoke check (or reports only), ensuring the tester
prompt references the orchestrator's final-gate ownership and explicitly defers
to the PR CI outcome; edit the tester prompt block (the prompt around Line 93)
and any mentions of "final gate" or "run the full suite" to reflect this
delegation to avoid duplicate long-running integration runs.
PRD #621 — Per-Request Path, Branch, and Credential for the Prompts Repo Override
Closes #621.
Extends the per-request prompts-repo override (PRD #581 / #607) so a secondary
--reposource can live under a subdirectory, on a non-default branch, and in a different auth realm. All three additions are optional and additive — a request that sends none of them is byte-identical to v1.21.0 (repo root,mainbranch, server env credential).What changed
?path=/?branch=onGET /api/v1/promptsandPOST /api/v1/prompts/:name(query), andpath/branchonPOST /api/v1/prompts/refresh(body) → threaded into the overridesubPath/branch.X-Dot-AI-Git-Tokenrequest header authenticates the override clone against its source host, taking precedence over the server'sDOT_AI_GIT_TOKENfor that request only. Header-only (never query/body); added to both CORS allowlists via a new single-sourcecors-headers.ts.gitspawn + host-boundGIT_ASKPASS(token never on the git argv, never in.git/config, never in the cache key). Token-bearing requests clone in per-request isolation so a private source is never served from/into the shared cache.rest-api.md,prompts.md) and a changelog fragment added.Security hardening (audit + review findings, all remediated)
X-Dot-AI-Git-Token,Authorization, …) redacted in debug logs..git/config(host-bound askpass; no embedded-URL credential).0700with a UUID name; clone errors scrubbed; cleanup failures warned (not swallowed).Tests / CI
repoand?repo=-only paths, with the header present, unchanged + no token leak), path/branch resolution + invalid-value400s, CORS preflight, and an end-to-end token-clone test.Deferred (post-merge / release)
publish-mock-serverat release time.vfarcic/dot-ai-cli(--repo-path/--repo-branch/ token forwarding) — drafted, to be filed after this PR's review.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests