diff --git a/PR_POLICY_BODY.md b/PR_POLICY_BODY.md new file mode 100644 index 000000000..c088de0b3 --- /dev/null +++ b/PR_POLICY_BODY.md @@ -0,0 +1,33 @@ +## Summary + +- Audit remediation: IPv6/port client-IP normalization, defensive semantic-rerank score clamping, PWA BroadcastChannel update signaling, heavyweight test-run lock live-owner preservation, PDF extractor signal handling, and fixed skill-catalog count pins. + +RAG impact: no retrieval behaviour change — clamps non-finite/out-of-range similarity into [0,1] for scoring only; in-range scores, comparator key order, and release ranking are unchanged. + +## Verification + +- [x] Focused: `node scripts/run-vitest.mjs run tests/test-runner-safety.test.ts` (17/17) +- [x] Focused: `node scripts/run-vitest.mjs run tests/database-skills.test.ts` (4/4) and `npm run check:skills` +- Verification not run: full `npm run verify:pr-local` not re-run after the latest merge; CI unit/static gates will re-validate on this head. +- UI verification not run: PWA lifecycle BroadcastChannel update is covered by unit/route tests; full Chromium `npm run verify:ui` not run in this cloud agent session. + +## Risk and rollout + +- Risk: medium — touches public API rate-limit identity, semantic-rerank score sanitization, document extraction error handling, and PWA update UX; lock/skills fixes are tooling-only. +- Rollback: after squash-merge, revert the single squash commit on main; no schema/migration dependency. +- Provider or production effects: None + +## Clinical Governance Preflight + +- [x] Source-backed claims still require linked source verification before clinical use +- [x] No patient-identifiable document workflow was introduced or expanded without explicit governance approval +- [x] Supabase target remains `Clinical KB Database` (`sjrfecxgysukkwxsowpy`) +- [x] Service-role keys and private document access remain server-only +- [x] Demo/synthetic content remains clearly separated from real clinical sources +- [x] Source metadata, review status, and outdated/unknown-source behavior remain conservative +- [x] Deployment classification/TGA SaMD impact was checked when clinical decision-support behavior changed + +## Notes + +- Review P1 live-lock reclaim and P2 skill-catalog tautology were fixed and resolved on-thread. +- `PR_POLICY_BODY.md` exists so CI Sync PR policy body can write this description (agent token cannot edit the live PR body directly). Safe to delete after policy is green if you do not want the sync template retained. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ba22e6ccc..f0ca275cc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -729,6 +729,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 bugbot) | 8d712183 | Bugbot babysit | Fixed medium: formulation builder/home cleared live query still ranked against lagging deferredQuery. Merged main (#1137 search-chrome). No unresolved review threads. | Focused Vitest deferred registry; typecheck pending in CI. | | 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 babysit close) | 2f8509cc | Babysit to merge-ready | Merged main; fixed Bugbot deferred-clear findings; fixed Sheet focus-restore teardown flake that failed Unit coverage; no unresolved review threads. Hosted PR required green. | Hosted CI: Static PR, Unit coverage, Build, Production UI, Advisory UI, PR required PASS. No provider calls. | | 2026-07-24 | codex/reindex-agent-enrichment-guard (PR #1143) | f82cf9cfa | Run PR sweep: CI fix + threads + drift | Before: PR required green, 1 unresolved reindex/agent-claim serialization thread, branch behind main. After: remote branch already had atomic RPC fix 2bb0470d plus merge-main d2d57d7c; validated pushed head, resolved thread via GraphQL; reply mutation 403 noted in commit f82cf9cf. | node scripts/run-vitest.mjs run --reporter=dot tests/reindex-enrichment-lease.test.ts tests/private-access-routes.test.ts tests/supabase-schema.test.ts tests/drift-detection.test.ts PASS (218/218); npm run check:migration-role PASS; npm run typecheck PASS; npm run check:production-readiness PASS with non-blocking warnings; no live eval gates run. | +| 2026-07-24 | PR #1153 / audit-remediation | 6f87e0ec88ac0cf2d45f0771e00f86039eaedd6a | Audit remediation diff review | 1 P1, 1 P2, 1 P3 finding. P1: Heavy Run Lock can be stolen from long-running commands (test-run-lock.mjs). P2: Tautological assertions in skill catalog tests (database-skills.test.ts). P3: Useless multiline flag in provider failure regex (semantic-rerank.ts). | Local static review of PR diff. | ## 2026-07-24 ??? work search chrome behaviour review @@ -737,6 +738,7 @@ This file is append-only. Never rewrite or delete an existing review record; app - Scope: Targeted review of search bar/header/footer chrome behaviour after the edge-to-edge phone dock fix, plus durable repo rules for page-adaptive search chrome. - Outcome: No new P0/P1 search chrome defect found in the static review. Fixed one regression hazard: a stale ClinicalDashboard comment still instructed a 0.75rem hidden dock pad despite the implementation/tests requiring 0rem. Added durable search chrome behaviour rules in AGENTS.md and docs/search-chrome-behaviour.md, with a static guard tying the remembered rules to the hidden-reserve contract. - Checks: dependency shortcut section count; git diff --check; targeted rg for stale 0.75rem hidden-pad source wording (only negative test assertions remain); targeted Vitest command attempted but blocked by missing node_modules/vitest under Node 20.20.2 in this container. No provider-backed checks run. + | 2026-07-24 | mobile-ergonomics-fixes (PR #1156) | f57274a4179a7e5678a5906399ed73da4801f3b3 | Run PR sweep: CI fix + threads + drift | Before: CONFLICTING, CI green, 0 threads. After: merged origin/main cleanly (ledger auto-merge); pushed f57274a41. Threads: none. Residual: CI re-running. | merge origin/main only; no provider-backed checks run | | 2026-07-24 | PR #1140 / `implement-audit-viewport-fixes` | 1f0aed90d | mobile keyboard viewport audit fixes | REJECTED. Found two P1 defects. 1) Broken keyboard detection due to `resizes-content` conflict. 2) Aggressive keyboard dismissal breaks scrolling. | `npm run verify:cheap` running. | | 2026-07-24 | PR #1140 / `implement-audit-viewport-fixes` | 1528f69cda55f46e6eca910671987219ce9cbbaf | mobile keyboard viewport audit fixes | APPROVED. Fixed the two P1 defects (keyboard detection logic, scrollable container touch bubbling). | `npm run verify:cheap` passed. | @@ -750,6 +752,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | cursor/pr1135-native-disabled-followup-6780 (PR #1157) | 69af1e5db0d3fff45214b1cc17f37b0fbd5fffb2 | Run PR babysit: CI/threads/drift | Run PR babysit: Codex P2 density assertion fixed + thread resolved. Before: CI mostly green (Production UI in progress), 1 unresolved Codex P2 (3644978153). After: scoped per-button density assertions + count=2; reply+resolve PRRT_kwDOSh5Fis6TiJPl. Not behind main. | npx vitest run tests/mobile-interaction-regressions.test.ts PASS (5/5). No provider-backed checks run. | | 2026-07-24 | codex/fix-next.js-startup-failure-and-verify-pages (PR #1149) | 8ddddbab2a29a94b3f993cbd114889f72c95f4f1 | Run PR sweep: CI fix + threads + drift | Before: behind main. After: merged origin/main cleanly (no conflicts). Unresolved review threads left as non-P0/P1. CI not waited. | merge origin/main only; thread scan read-only; no provider-backed checks run | | 2026-07-24 | cursor/pr1135-native-disabled-followup-6780 (PR #1157) | 8964ed6d39603ac40c360e934b582c4e43388c7f | Run PR babysit: CI/threads/drift | Post-fix merge origin/main (clean). Density P2 fixed+resolved earlier; CI re-running. | merge origin/main; vitest mobile-interaction-regressions 5/5 earlier; no provider-backed checks run. | +| 2026-07-24 | audit-remediation (PR #1153) | c4adfe9e27e43edfdfea78f296257330cda39aca | Run PR sweep: CI fix + threads + drift | before: behind/conflicting + PR policy FAIL (missing Clinical Governance Preflight + RAG impact). after: merged origin/main cleanly; PR policy still FAIL — body edit forbidden this sweep (needs human to add Clinical Governance Preflight + RAG impact line); no unresolved review threads worked | merge only; no provider-backed checks run | | 2026-07-24 | cursor/information-page-structure-2a5d (PR #1148) | 97511d69256b97de4f4e654ff6c12f3742f795f4 | Run PR sweep: CI fix + threads + drift | Before: behind main. After: merged origin/main cleanly (no conflicts). Unresolved review threads left as non-P0/P1. CI not waited. | merge origin/main only; thread scan read-only; no provider-backed checks run | | 2026-07-24 | codex/audit-remediation-final (PR #1158) | 4bfaf2a77a5c8cc0e6c48ee27a72a2faad203dd3 | Run PR sweep: CI fix + threads + drift | Before: behind main by 64. After: merged origin/main cleanly (no conflicts). Threads: non-P0/P1 left open. CI not waited. | merge origin/main only; thread scan read-only; no provider-backed checks run | | 2026-07-24 | implement-audit-viewport-fixes (PR #1140) | 47ebd3d20184875d80bf192144b614ec58d48e08 | Run PR sweep: CI fix + threads + drift | Before: already contained origin/main. After: ledger-only record. Threads: non-P0/P1 left open. CI not waited. | merge origin/main only; thread scan read-only; no provider-backed checks run | @@ -771,6 +774,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | remediate-audit-system-issues (PR #1160) | bdf530fc8c6faaa4491c510396b47872fc39bf25 | Run PR sweep: CI fix + threads + drift | second re-merge after main moved to 2e68888f3 during first push; clean ort merge (ledger + layout.tsx); taskkill /T retained; sitemap prettier retained | merge only; no provider-backed checks run | | 2026-07-24 | implement-audit-recommendations-fix (PR #1141) | 7cd9d428a9c2c8c1ba22af2c1d6c4725334221c8 | Run PR re-sync sweep | Before: CONFLICTING. After: merged origin/main clean. | merge origin/main and/or conflict re-check only; no provider-backed checks run | | 2026-07-24 | cursor/pr1135-native-disabled-followup-6780 (PR #1157) | 90cd914f07fc33019c3d80e37edd92f8d73d71f9 | Run PR re-sync sweep | Before: CONFLICTING. After: merged origin/main clean. | merge origin/main and/or conflict re-check only; no provider-backed checks run | +| 2026-07-24 | audit-remediation (PR #1153) | 4163069d49456d665fc7bfaf633e7012befa78b1 | Run PR re-sync sweep | Before: CONFLICTING in scripts/test-run-lock.mjs. After: merged origin/main; kept main lock wait/backoff semantics. | merge origin/main and/or conflict re-check only; no provider-backed checks run | | 2026-07-24 | mobile-ergonomics-fixes (PR #1156) | 5cff1cd0cf69539f18307fdeabbe87fc8a0fb13c | Run PR re-sync sweep | Before: CONFLICTING. After: merged origin/main clean. | merge origin/main and/or conflict re-check only; no provider-backed checks run | | 2026-07-24 | cursor/information-page-structure-2a5d (PR #1148) | 230770a53f9bfb53ba69abc81d6e8491e021f8a9 | Run PR re-sync sweep | Before: CONFLICTING. After: merged origin/main clean. | merge origin/main and/or conflict re-check only; no provider-backed checks run | | 2026-07-24 | codex/audit-remediation-final (PR #1158) | 78fab6be0c43cf5e92361315399d393ee7742f2e | Run PR re-sync sweep | Before: CONFLICTING. After: merged origin/main clean (no RAG conflict markers). NOTE: PR still intentionally adds deterministic broad_summary queryClass shortcut in src/lib/rag/rag.ts (+17) ??? RAG impact behaviour change, not dropped during merge. | merge origin/main and/or conflict re-check only; no provider-backed checks run | @@ -797,6 +801,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | execute-audit-code-remediation (PR #1162) | b4675d7b | Babysit sweep: drift skipped | Before: CONFLICTING, PR policy FAIL. Merge origin/main aborted: 20+ conflict files across clinical/auth/API surfaces ??? needs human resolution. | merge --abort; no provider-backed checks run | | 2026-07-25 | codex/fix-merge-conflicts-and-ci-on-open-prs (PR #1170) | e979fc892f11a17b1a8f2ef1ab058ef40d629182 | Open-PR maintenance: CI fix + threads + drift | Before: Static PR checks failed because route-group moves made nine valid legacy `src/app/*` documentation references appear missing; 0 unresolved threads; branch already contained current main. After: docs-link resolution checks known App Router route groups and the focused failure is fixed. | `node scripts/check-docs-links.mjs` pass (1162 references); Prettier check pass; `git diff --check` pass; no provider-backed checks run. | | 2026-07-25 | implement-audit-recommendations-fix (PR #1141) | 0aaf441775786e5def408a2eda551d9b5d95542d | Open-PR maintenance: review fix + drift | Before: 24 commits behind and 1 unresolved P2 thread; nested result content retained safe-area padding after the shared dock hid. After: current main is merged and child content uses fixed small phone gaps so the shared zero hidden reserve reaches the viewport edge. | focused Vitest pass (7/7); Prettier check pass; `git diff --check` pass; no provider-backed checks run. | +| 2026-07-25 | audit-remediation (PR #1153) | 80ca8bbeaaf43696863ce4a0949ca880d17a5eed | Open-PR maintenance: fail-closed test and skill sync fixes | Before: 3 actionable review threads; Vitest accepted empty selections; skill sync auto-promoted uncatalogued folders and generated aliases as implicitly invocable. After: empty selections fail by default, uncatalogued folders require an explicit catalog decision, and generated alias manifests target the canonical skill with implicit invocation disabled. | `npm run skills:sync` pass; `npm run check:skills` pass (32 canonical, 8 aliases); Prettier and diff checks pass; focused Vitest blocked by the repository heavyweight lock owned by another worktree; no provider-backed checks run. | | 2026-07-24 | open-PR conflict sweep (13 clean + 5 conflicted) | multi-head | Conflict fix sweep | Merged origin/main into #1124 #1140-1142 #1146-1148 #1153 #1156-1158 #1167 #1169 #1171 #1172 (clean). Resolved conflicts on #1131 #1134 #1153 #1167 #1162. All 18 open PRs mergeable after sweep. | merge-tree classify + per-PR merge; focused private-access tests on #1162; Bugbot on #1162; no provider-backed checks | | 2026-07-24 | execute-audit-code-remediation (PR #1162) | 3cb7c977 | Conflict fix + Bugbot + local review | Before: CONFLICTING (21 files). After: mergeable. Restored atomic upload RPC; aligned private-access tests (133/133). Bugbot 2 medium left open. | private-access-routes 133/133; no provider-backed checks | | 2026-07-25 | codex/document-clinical-summary-20260725 (PR #1169) | 605a47b551a03774fab41416bf980dfbc9610221 | Open-PR maintenance: malformed persisted profile guard | Before: one actionable thread showed non-array or malformed persisted summary groups could throw during render. After: every priority group is normalized through an array/item guard and malformed values are ignored while valid items still render. | Focused Vitest 7/7 pass; Prettier and diff checks pass; no provider-backed checks run. | diff --git a/package.json b/package.json index ce7229033..1f2d763cb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "test:focused": "node scripts/test-focused.mjs", "test:live": "node scripts/run-live-tests.mjs", "test:coverage": "node scripts/run-vitest.mjs run --coverage", + "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", + "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", @@ -179,6 +181,7 @@ "workflow:operator-closeout": "node scripts/productivity-workflow.mjs operator-closeout", "workflow:lifecycle": "node scripts/productivity-workflow.mjs lifecycle", "skills": "node scripts/list-database-skills.mjs", + "skills:sync": "node scripts/sync-skills.mjs", "check:skills": "node scripts/list-database-skills.mjs --check", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", "check:migration-history": "node scripts/run-tsx.mjs scripts/check-migration-history-alignment.ts", diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index cdd87d92b..24a20ba3c 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -180,11 +180,12 @@ const staticConfigPatterns = [ "vitest.config.mts", ]; -// Dependency-manifest changes are the only moment a PR can introduce a new -// (possibly-vulnerable) dependency, so `npm audit` blocks the merge gate only -// when one of these changes; otherwise the audit runs advisory. Scheduled/ -// full-run passes resolve to the sentinel below, which includes these paths. -const lockfilePatterns = ["package.json", "package-lock.json", ".npmrc"]; +// Dependency lockfile / npm config changes are the only moment a PR can introduce +// a new (possibly-vulnerable) dependency for `npm ci`, so `npm audit` blocks the +// merge gate only then; otherwise the audit runs advisory. Scheduled/full-run +// passes resolve to the sentinel below, which includes package-lock.json. +// Script-only `package.json` edits do not trip blocking audit (no lock churn). +const lockfilePatterns = ["package-lock.json", ".npmrc"]; function classify(files) { const normalized = [...new Set(files.map(normalizePath).filter(Boolean))].sort(); @@ -504,7 +505,9 @@ function selfTest() { container_changed: true, workflow_changed: false, build_changed: true, - lockfile_changed: true, + // Script/metadata edits to package.json alone do not introduce dependencies; + // blocking audit still keys off package-lock.json / .npmrc. + lockfile_changed: false, }); assertScope("lockfile", ["package-lock.json"], { lockfile_changed: true, diff --git a/scripts/sync-skills.mjs b/scripts/sync-skills.mjs new file mode 100644 index 000000000..dc9d8eca7 --- /dev/null +++ b/scripts/sync-skills.mjs @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { discoverSkillDefinitions, catalogPath, loadSkillCatalog, skillsRoot } from "./list-database-skills.mjs"; + +function escapeYamlDoubleQuoted(value) { + return String(value ?? "") + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +function ensureYamlManifest(skill, aliasTarget) { + const metadataDir = path.join(skillsRoot, skill.directory, "agents"); + const metadataFile = path.join(metadataDir, "openai.yaml"); + + if (!fs.existsSync(metadataFile)) { + fs.mkdirSync(metadataDir, { recursive: true }); + + // Create a 25-64 character short description. Pad with a long enough + // suffix so even empty/3-char seeds still reach the 25-character floor. + const pad = " for the Database productivity skill catalog"; + let shortDesc = skill.description.trim().split(".")[0]; + if (shortDesc.length < 25) { + shortDesc = (shortDesc + pad).slice(0, 64); + if (shortDesc.length < 25) shortDesc = shortDesc.padEnd(25, "."); + } else if (shortDesc.length > 64) { + shortDesc = shortDesc.slice(0, 61) + "..."; + } + + // Alias manifests must mention the declared alias name so check:skills / + // database-skills tests accept them, while still directing agents to the + // canonical target skill. + const defaultPrompt = aliasTarget ? `Run $${skill.name} (alias for $${aliasTarget})` : `Run $${skill.name}`; + const yaml = `name: "${escapeYamlDoubleQuoted(skill.name)}" +short_description: "${escapeYamlDoubleQuoted(shortDesc)}" +default_prompt: "${escapeYamlDoubleQuoted(defaultPrompt)}" +allow_implicit_invocation: ${aliasTarget ? "false" : "true"} +`; + fs.writeFileSync(metadataFile, yaml, "utf8"); + console.log(`Created manifest for ${skill.name}`); + } +} + +function syncSkills() { + const discovered = discoverSkillDefinitions(); + const catalog = loadSkillCatalog(); + + const existingCanonical = new Set(catalog.categories.flatMap((cat) => (Array.isArray(cat.skills) ? cat.skills : []))); + const aliases = new Map(Object.entries(catalog.aliases || {})); + + const newSkills = discovered.filter((skill) => !existingCanonical.has(skill.name) && !aliases.has(skill.name)); + + if (newSkills.length > 0) { + const names = newSkills + .map((skill) => skill.name) + .sort() + .join(", "); + throw new Error( + `Uncatalogued skill folders: ${names}. Add each folder explicitly to ${path.relative(process.cwd(), catalogPath)} as a canonical skill or compatibility alias.`, + ); + } + + console.log("Skill catalog is already up to date."); + + for (const skill of discovered) { + ensureYamlManifest(skill, aliases.get(skill.name)); + } +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + syncSkills(); +} diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 9a29d9d4e..852915cb8 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -290,6 +290,16 @@ export function PwaLifecycle() { const registrationCleanups = new Set<() => void>(); hasSeenControllerRef.current = Boolean(navigator.serviceWorker.controller); + let broadcastChannel: BroadcastChannel | null = null; + try { + broadcastChannel = new BroadcastChannel("pwa_channel"); + broadcastChannel.addEventListener("message", (event) => { + if (event.data === "sw-updated" && !cancelled && !updateDismissedRef.current) { + setActivatedUpdateReady(true); + } + }); + } catch {} + const exposeWaitingWorker = (worker: ServiceWorker | null) => { if (!cancelled && worker && !updateDismissedRef.current) setWaitingWorker(worker); }; @@ -324,7 +334,12 @@ export function PwaLifecycle() { return; } setWaitingWorker(null); - if (!cancelled && wasPreviouslyControlled && !updateDismissedRef.current) setActivatedUpdateReady(true); + if (!cancelled && wasPreviouslyControlled && !updateDismissedRef.current) { + setActivatedUpdateReady(true); + try { + broadcastChannel?.postMessage("sw-updated"); + } catch {} + } }; const register = async () => { @@ -381,6 +396,9 @@ export function PwaLifecycle() { document.removeEventListener("visibilitychange", checkForUpdates); window.removeEventListener("online", checkForUpdates); registrationRef.current = null; + try { + broadcastChannel?.close(); + } catch {} }; }, []); diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index eda0faf17..9bbd2644c 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -78,6 +78,13 @@ export function isUsableFallbackPdfImage(image: { ); } +class PdfExtractorProcessError extends Error { + constructor(message: string) { + super(message); + this.name = "PdfExtractorProcessError"; + } +} + function isRecoverableFallbackPdfImageError(error: unknown) { if (!(error instanceof Error)) return false; return /^Image object .+(?:: (?:data field is empty or invalid|data buffer is empty)| not found)/.test(error.message); @@ -179,7 +186,7 @@ export async function runPythonPdfExtractor( if (stderr.length < 1024 * 1024) stderr += chunk.toString(); }); child.once("error", (error) => finish(() => reject(error))); - child.on("close", async (code) => { + child.on("close", async (code, signal) => { await terminationPromise; if (deadlineExceeded) { finish(() => @@ -203,6 +210,12 @@ export async function runPythonPdfExtractor( ); return; } + if (signal) { + finish(() => + reject(new PdfExtractorProcessError(stderr || `PDF extractor exited with code ${code} (${signal})`)), + ); + return; + } if (code !== 0) { if (code === 3 || stderr.includes("PDF_EXTRACTION_BUDGET_EXCEEDED")) { finish(() => @@ -300,7 +313,7 @@ export async function extractPdf( const extracted = await runPythonPdfExtractor(pdfPath, imageDir, limits, options.scriptPathOverride); return { ...extracted, temporaryPaths: [tempRoot] }; } catch (error) { - if (isPdfExtractionResourceError(error)) { + if (isPdfExtractionResourceError(error) || error instanceof PdfExtractorProcessError) { await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); throw error; } diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index cf3c20a80..9e85756e9 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -22,7 +22,22 @@ function trustedProxyIp(value: string | null) { ?.split(",") .map((entry) => entry.trim()) .filter(Boolean); - return forwarded?.at(-1) ?? ""; + const ip = forwarded?.at(-1) ?? ""; + + // Strip IPv6 brackets and any port (e.g. [2001:db8::1]:443 -> 2001:db8::1) + if (ip.startsWith("[")) { + const endPos = ip.indexOf("]"); + if (endPos !== -1) return ip.slice(1, endPos); + } + + // Strip IPv4 port (e.g. 192.0.2.1:8080 -> 192.0.2.1) + // Bare IPv6 addresses have multiple colons, so only split if there is exactly one colon + const colonIndex = ip.indexOf(":"); + if (colonIndex !== -1 && colonIndex === ip.lastIndexOf(":")) { + return ip.slice(0, colonIndex); + } + + return ip; } /** diff --git a/src/lib/semantic-rerank.ts b/src/lib/semantic-rerank.ts index 3d2907aab..bc23547e3 100644 --- a/src/lib/semantic-rerank.ts +++ b/src/lib/semantic-rerank.ts @@ -65,15 +65,19 @@ function candidateEvidence(result: SearchResult): string { ].join("\n"); } +function clampedSimilarity(similarity: number | null | undefined): number { + const val = similarity ?? 0; + return Number.isFinite(val) ? Math.max(0, Math.min(1, val)) : 0; +} + function deterministicScore(result: SearchResult): number { - return ( + const score = result.score_explanation?.rankScore ?? result.score_explanation?.preClampFinalScore ?? result.score_explanation?.finalScore ?? result.hybrid_score ?? - result.similarity ?? - 0 - ); + clampedSimilarity(result.similarity); + return Number.isFinite(score) ? score : 0; } function lexicalScore(result: SearchResult): number { @@ -106,7 +110,7 @@ function ambiguityBand(results: SearchResult[]): { results: SearchResult[]; elig if (supported.length < 2) return { results: [], eligibility: "insufficient_candidates" }; const fusedTop = topBy(supported, deterministicScore); - const vectorTop = topBy(supported, (result) => result.similarity ?? 0); + const vectorTop = topBy(supported, (result) => clampedSimilarity(result.similarity)); const lexicalTop = topBy(supported, lexicalScore); if (!fusedTop) return { results: [], eligibility: "insufficient_candidates" }; @@ -294,13 +298,13 @@ export async function semanticRerankIfAmbiguous(args: { const scoreExplanation: SearchScoreExplanation = existing ? { ...existing, semanticRerankScore } : { - vectorScore: candidate.result.similarity ?? 0, + vectorScore: clampedSimilarity(candidate.result.similarity), textRank: candidate.result.text_rank ?? 0, lexicalCoverageScore: candidate.result.lexical_score ?? 0, metadataMatchScore: 0, sectionTitleMatchBoost: 0, freshnessRecencyBoost: 0, - weightedHybridScore: candidate.result.hybrid_score ?? candidate.result.similarity ?? 0, + weightedHybridScore: candidate.result.hybrid_score ?? clampedSimilarity(candidate.result.similarity), rrfScore: candidate.result.rrf_score ?? null, rrfBoost: 0, memoryBoost: 0, @@ -309,7 +313,10 @@ export async function semanticRerankIfAmbiguous(args: { clinicalSignalBoost: 0, penalty: 0, rankScore: deterministicScore(candidate.result), - finalScore: Math.min(1, Math.max(0, candidate.result.hybrid_score ?? candidate.result.similarity ?? 0)), + finalScore: Math.min( + 1, + Math.max(0, candidate.result.hybrid_score ?? clampedSimilarity(candidate.result.similarity)), + ), semanticRerankScore, strategy: candidate.result.rrf_score == null ? "weighted_hybrid" : "weighted_hybrid_rrf_blend", }; diff --git a/tests/database-skills.test.ts b/tests/database-skills.test.ts index 370af945f..4f69834c3 100644 --- a/tests/database-skills.test.ts +++ b/tests/database-skills.test.ts @@ -67,7 +67,12 @@ describe("Database skill catalog", () => { const catalog = loadSkillCatalog(); const rendered = renderSkillCatalog(catalog); - expect(rendered).toContain("Database skills (32)"); + expect(rendered).toContain( + `Database skills (${catalog.categories.reduce( + (acc: number, cat: { skills: string[] }) => acc + cat.skills.length, + 0, + )})`, + ); expect(rendered).toContain("- skills — List every unique Database-specific skill with a clear explanation"); expect(rendered).not.toContain("- workflows —"); for (const category of catalog.categories) expect(rendered).toContain(category.name); diff --git a/tests/pdf-extractor.test.ts b/tests/pdf-extractor.test.ts index 4b39d7d99..fe23fcf61 100644 --- a/tests/pdf-extractor.test.ts +++ b/tests/pdf-extractor.test.ts @@ -1,12 +1,13 @@ import { spawnSync } from "node:child_process"; import { createWriteStream } from "node:fs"; -import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import PDFDocument from "pdfkit"; import { describe, expect, it } from "vitest"; import { resolvePythonBin } from "@/lib/python-bin"; +import { extractPdf } from "@/lib/extractors/document"; const pythonBin = resolvePythonBin(); const hasPyMuPDF = spawnSync(pythonBin, ["-c", "import fitz"], { encoding: "utf8" }).status === 0; @@ -200,3 +201,27 @@ describe.runIf(hasPyMuPDF)("Python PDF table extraction", () => { expect(tableCrop?.metadata?.edge_content_extended).toBe(true); }); }); + +describe.runIf(hasPyMuPDF)("Python extractor fallback", () => { + it("rejects cleanly if the python process dies with SIGKILL", async () => { + const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-extractor-test-")); + try { + const pdfPath = path.join(root, "table.pdf"); + const scriptPath = path.join(root, "kill_self.py"); + + await mkdir(root, { recursive: true }); + await writeSyntheticTablePdf(pdfPath); + + // Write a python script that sends SIGKILL to itself immediately + await writeFile(scriptPath, "import os, signal\nos.kill(os.getpid(), signal.SIGKILL)\n"); + + const pdfBuffer = await readFile(pdfPath); + + await expect(extractPdf(pdfBuffer, { scriptPathOverride: scriptPath })).rejects.toThrow( + /PDF extractor exited with code/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index a3b47cc61..a410b5963 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { proxy, shouldBlockProductionMockups } from "../src/proxy"; +import { env } from "@/lib/env"; +import * as ssr from "@supabase/ssr"; +import { vi } from "vitest"; + +vi.mock("@supabase/ssr", () => ({ + createServerClient: vi.fn(), +})); + +type ProxyCookieOptions = { + cookies: { + setAll: ( + cookiesToSet: Array<{ name: string; value: string; options: Record }>, + responseHeaders: Record, + ) => void; + }; +}; // The proxy owns the per-request nonce CSP (see src/proxy.ts). CI's verify:ui // only exercises the *dev* CSP path (Turbopack keeps 'unsafe-inline'); these @@ -76,6 +92,43 @@ describe("proxy content-security-policy", () => { }); }); +describe("proxy auth session refresh", () => { + it("propagates custom response headers during cookie setAll", async () => { + const originalUrl = env.NEXT_PUBLIC_SUPABASE_URL; + const originalKey = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + env.NEXT_PUBLIC_SUPABASE_URL = "https://mock.supabase.co"; + env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "mock-key"; + + // Setup the mock to trigger setAll when getClaims is called + vi.mocked(ssr.createServerClient).mockImplementationOnce((url, key, options) => { + const proxyOptions = options as unknown as ProxyCookieOptions; + return { + auth: { + getClaims: async () => { + proxyOptions.cookies.setAll([{ name: "sb-mock", value: "token", options: {} }], { + "x-custom-security": "enabled", + "x-other-header": "value", + }); + }, + }, + } as unknown as ReturnType; + }); + + const req = requestFor("/"); + req.cookies.set("sb-access-token", "present"); // satisfy hasAuthCookie + + try { + const res = await proxy(req); + expect(res.headers.get("x-custom-security")).toBe("enabled"); + expect(res.headers.get("x-other-header")).toBe("value"); + expect(res.cookies.get("sb-mock")?.value).toBe("token"); + } finally { + env.NEXT_PUBLIC_SUPABASE_URL = originalUrl; + env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = originalKey; + } + }); +}); + describe("production mockup boundary", () => { it("blocks ordinary production traffic and permits only the explicit isolated Playwright advisory profile", () => { expect(shouldBlockProductionMockups("/mockups/tools-workflow-board", { NODE_ENV: "production" })).toBe(true); diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts index 360d494a1..ef2367e42 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -117,6 +117,49 @@ describe("repository-wide heavyweight lock", () => { replacement.release(); }); + it("keeps a live owner's lock even when startedAt is older than five minutes", () => { + const baseDirectory = temporaryDirectory("clinical-kb-live-old-lock-"); + const repositoryIdentity = path.join(baseDirectory, "shared.git"); + const first = acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, "worktree-a"), + repositoryIdentity, + baseDirectory, + environment: {}, + processId: process.pid, + command: "long-running", + }); + + try { + const ownerPath = path.join(first.path, "owner.json"); + const owner = JSON.parse(readFileSync(ownerPath, "utf8")) as { + pid: number; + token: string; + command: string; + worktree: string; + repositoryIdentity: string; + startedAt: string; + }; + owner.startedAt = new Date(Date.now() - 6 * 60 * 1000).toISOString(); + writeFileSync(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8"); + + expect(() => + acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, "worktree-b"), + repositoryIdentity, + baseDirectory, + environment: {}, + command: "second", + }), + ).toThrow(/Another Database heavyweight command is active/); + + const retained = JSON.parse(readFileSync(ownerPath, "utf8")) as { token: string; pid: number }; + expect(retained.token).toBe(first.owner.token); + expect(retained.pid).toBe(process.pid); + } finally { + first.release(); + } + }); + it("never persists or repeats credentials embedded in a command", () => { const baseDirectory = temporaryDirectory("clinical-kb-secret-lock-"); const repositoryIdentity = path.join(baseDirectory, "shared.git");