fix: dedupe insightsService's transport onto the shared aiProvider one - #5626
Conversation
|
Appreciate the detailed writeup on this one — pinning the exact behavioral divergence (line-referenced, both sides) made it straightforward to confirm which classification was canonical instead of guessing. Let me know if you'd rather see the moved test coverage organized differently. |
atomantic
left a comment
There was a problem hiding this comment.
Reviewed by /do:review (security-focused pass, external contributor) — 0 critical, 2 improvements. This one is good; rebase and it's mergeable. Both findings are non-blocking and we're happy to take them in a follow-up if you'd rather not spin another round.
Rebase required. The branch is 188 commits behind main and currently CONFLICTING. In this repo a conflicted PR gets no CI at all ("no checks reported"), so nothing here has been pipeline-verified. The single conflict is an append-at-EOF collision in aiProvider.test.js (main added a Codex-subscription block at the same spot) — keep both sides, no semantic overlap.
Verified locally (isolated worktree, rebased onto current main, conflict resolved by keeping both blocks):
- Full
cd server && npm test→ 1809 files / 37248 tests pass, exit 0. - Transport parity checked field by field, since a dedupe is only safe if the survivor is equivalent:
- Timeout: identical — both
provider.timeout || 300000. - Endpoint guard: preserved exactly, and now enforced inside
postChatCompletion, so the SSRF / key-exfiltration policy, theallowCustomEndpointopt-in, and keyless local-LLM passthrough all behave the same. Your three new guard tests pin it at the new home. - Non-2xx: identical message shape (
Provider returned {status}: {body}). - Ollama readiness: improved — the old copy called
ensureProviderReadyfor everytype === 'api'provider; the shared one scopes it toisOllamaProvider, which is what it always meant. - Empty-content 200: changed — see the inline comment; net improvement, but it needs to be in the description.
- Timeout: identical — both
- Security scan: pure ASCII apart from em-dashes, no network calls, no
process.envreads, noeval/exec/child_process, no dependency or CI changes, no new secrets path. Single commit, single author. Net -59 lines and one fewer copy of a keyed-request transport, which is a genuine security win on its own — one place to audit instead of two.
The two notes:
- The empty-completion verdict flips from
{ text: '' }success to{ error }. Good change (it's what finally stops an empty narrative overwriting the cache) and already pinned onmain, but the PR reads as a pure dedupe and doesn't mention it. - The read-path no-cold-bootstrap tests drop from a
fetchWithTimeout-level assertion to acallProviderAISimple-level one, which a future non-callProviderAISimplepath would slip past.
Coherence check: description matches the diff otherwise, and the test plan holds up — I re-ran it. Deleting the duplicated transport rather than wrapping it is the right call, and moving its contract tests to live beside the surviving implementation is exactly where they belong.
Generated by /do:review
insightsService.js defined a second callProviderAISimple, behaviorally
divergent from the canonical server/services/aiProvider.js one: it
classified a non-JSON/blank 200 body as null (surfacing an error, as
intended) but a *valid* 200 body with empty content as a successful
{ text: '' } - the opposite of aiProvider's classification, which
treats a whitespace-only completion as a provider error. Both
generateThemeAnalysis and refreshCrossDomainNarrative persist their
result unconditionally when result.error is unset, so the divergent
copy could write an empty narrative/theme over a real cached one on an
otherwise-valid-but-content-empty response.
Delete the duplicate and route both entry points through the shared
aiProvider.callProviderAISimple - same call shape, drop-in compatible.
The transport/malformed-body/SSRF-guard test matrix moves to
aiProvider.test.js (where the shared implementation now lives);
insightsService.test.js keeps only the disk-only-read-path contract,
per its own docstring's scope.
Fixes atomantic#5617
834b6e3 to
4cc7525
Compare
|
Rebased onto current `main` (single conflict, exactly as flagged — an append-at-EOF collision in `aiProvider.test.js` where `main`'s new Codex-subscription test block and this PR's malformed-response test block both landed at the same spot). Kept both in full: worth noting for anyone re-resolving a similar case, git's merge deduplicated the closing `});`/`});` lines it judged identical between the two sides and left them out of the conflict markers entirely — had to manually restore them after the auto-resolve to close `main`'s own `describe` block, or the file would have silently mis-nested the two blocks into one. Re: the empty-completion-verdict note — already called out explicitly in this PR's own description (first bullet: "a valid 200 body with empty content..."), so no separate edit needed there. Verified: `npx vitest run services/aiProvider.test.js services/insightsService.test.js` — 36/36 pass (up from the 24 in the original test plan, since `main` picked up new Codex-transport tests in the meantime). 🤖 Generated with Claude Code |
Addresses the maintainer's /do:review design question on atomantic#5625: routing the six server/services/* consumers through aiToolkit/index.js (the toolkit's composition root) pulled 24 modules including express and child_process, where the previous direct import to internal/ pulled 1 module and nothing else - three lines below a comment in aiProvider.js explaining why it deliberately avoids exactly that. Moves evaluateSecretEndpoint/assertSecretEndpoint from aiToolkit/internal/endpointGuard.js to aiToolkit/endpointGuard.js - a peer of aiToolkit/errorDetection.js, which already follows this exact "pure toolkit-root module, importable directly AND re-exported via the barrel" shape. index.js now does `export * from './endpointGuard.js'` instead of a named re-export, matching errorDetection.js's own pattern. All 8 consumers repointed at the new path: the 6 services from the original PR (aiProvider.js, askService.js, insightsService.js, localLlmPlayground.js, visionTest.js, voice/llm.js, plus visionTest.frameGuard.test.js's mock target), and 2 toolkit-internal callers the original PR's scope didn't touch (providers.js, runner.js) - both were still importing the pre-move internal/ path directly and would have broken had they been left pointed at a path that no longer exists. Also updated 3 doc-comment references to the old path (providers.js, aiToolkit/validation.js, lib/validation.js) for accuracy; left one reference alone (scripts/migrations/ 195-cerebras-provider.js) since it's a historical, already-applied migration's own comment, not live code. Verified: the 12 directly relevant test files pass (583 tests, plus 5 pre-existing intentional skips); a repo-wide grep confirms zero remaining references to the old path outside that one historical migration comment; lib/aiToolkit/runner.test.js's own EBUSY failures reproduce identically with or without this change (pre-existing Windows temp-file-lock flakiness, already confirmed unrelated on sibling PRs atomantic#5626/atomantic#5627 via git stash). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses the maintainer's /do:review design question on atomantic#5625: routing the six server/services/* consumers through aiToolkit/index.js (the toolkit's composition root) pulled 24 modules including express and child_process, where the previous direct import to internal/ pulled 1 module and nothing else - three lines below a comment in aiProvider.js explaining why it deliberately avoids exactly that. Moves evaluateSecretEndpoint/assertSecretEndpoint from aiToolkit/internal/endpointGuard.js to aiToolkit/endpointGuard.js - a peer of aiToolkit/errorDetection.js, which already follows this exact "pure toolkit-root module, importable directly AND re-exported via the barrel" shape. index.js now does `export * from './endpointGuard.js'` instead of a named re-export, matching errorDetection.js's own pattern. All 8 consumers repointed at the new path: the 6 services from the original PR (aiProvider.js, askService.js, insightsService.js, localLlmPlayground.js, visionTest.js, voice/llm.js, plus visionTest.frameGuard.test.js's mock target), and 2 toolkit-internal callers the original PR's scope didn't touch (providers.js, runner.js) - both were still importing the pre-move internal/ path directly and would have broken had they been left pointed at a path that no longer exists. Also updated 3 doc-comment references to the old path (providers.js, aiToolkit/validation.js, lib/validation.js) for accuracy; left one reference alone (scripts/migrations/ 195-cerebras-provider.js) since it's a historical, already-applied migration's own comment, not live code. Verified: the 12 directly relevant test files pass (583 tests, plus 5 pre-existing intentional skips); a repo-wide grep confirms zero remaining references to the old path outside that one historical migration comment; lib/aiToolkit/runner.test.js's own EBUSY failures reproduce identically with or without this change (pre-existing Windows temp-file-lock flakiness, already confirmed unrelated on sibling PRs atomantic#5626/atomantic#5627 via git stash). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
atomantic
left a comment
There was a problem hiding this comment.
Second /do:review pass (security-focused, external contributor) — 0 findings. Approving and merging. Both threads from the first round are answered and resolved.
What changed since round one: rebased onto current main, conflict resolved, and CI is now actually running — CI Gate pass, Server tests pass, Plan test impact pass. mergeStateStatus: CLEAN.
Re-verified independently on 4cc7525:
- Rebase resolution is correct.
aiProvider.test.jsat HEAD is byte-identical tomain's copy plus a pure append of the two newdescribeblocks — no mis-nesting, no swallowed brace,main's Codex-subscription block intact. This was the one thing worth checking twice, since a foldeddescribewould have silently changed hook scoping while staying green. - Production diff is a pure deletion.
insightsService.jsloses only the duplicate function and its now-unused imports/constant; grepped the file for every removed symbol (fetchWithTimeout,readResponseJson,ensureOllamaProviderReady,evaluateSecretEndpoint,DEFAULT_AI_TIMEOUT_MS) — zero residual references. Onlyserver/routes/insights.jsconsumes this module, viaimport * as, so the dropped named export breaks nothing. - Signature parity holds. Shared
callProviderAISimple(provider, model, prompt, options)carries the sametemperature = 0.3/max_tokens = 1000defaults and the sameprovider.timeout || 300000; both call sites pass their own overrides and gate onresult.errorbeforeatomicWrite, so the cache-overwrite path #5617 describes is closed. - Security scan, adversarial pass: no non-ASCII beyond em-dashes, no bidi or zero-width characters, no new
process.env/child_process/eval/ network calls, no dependency, lockfile, or CI changes. Net −59 lines and one fewer copy of a keyed-request transport — one place to audit instead of two, which is the real security win here. - The SSRF / key-exfiltration guard is preserved and now better covered. It lives in
postChatCompletion, and the three appended guard tests pin all three arms at the surviving implementation: blocked-when-keyed, allowed underallowCustomEndpoint, and keyless-passthrough. None of them weakens a guard — they pin behavior that was previously asserted only against the deleted copy, so the consolidation would otherwise have lost that coverage. - New transport tests are complementary, not duplicates —
mainalready covered whitespace-only and the happy path; this adds non-JSON body, blank body, and non-2xx.
Against the ticket (#5617): all five acceptance criteria met. The one soft spot is AC 3 — that an errored transport leaves themes.json / narrative.json untouched is proven transitively (transport errors + if (result.error) return) rather than by a test at the Insights boundary. That's pre-existing, not a regression, and we're taking it in our own follow-up along with the read-path assertion altitude.
Behavior deltas worth recording, all fallout of adopting the canonical transport rather than choices this PR made:
- An empty/whitespace-only completion flips from
{ text: '' }success to{ error }— stated in the description and commit body, and it is what finally stops an empty narrative overwriting the cache. - Non-API rejection message text changes (
Insights analysis requires an API-based provider→This operation requires an API-based provider). - Ollama readiness is now scoped by
isOllamaProviderinstead of firing for everytype === 'api'provider — the old copy's behavior was a bug. - Insights can now dispatch to a Codex subscription-text provider, which it previously refused outright.
Thanks — deleting the duplicate rather than wrapping it, and relocating its contract tests to sit beside the surviving implementation, is exactly the right shape for this.
|
Merged as The read-path assertion altitude I raised in the second thread is now our follow-up: #5845. It restores the wire-level |
…e persist guard at its callers Follow-up to atomantic#5626 (review threads we chose not to hold that PR open for). Two gaps the transport dedupe left in insightsService.test.js: The read-path assertions moved from a fetchWithTimeout-level spy down to a callProviderAISimple-level one when the transport moved to aiProvider. That is one export, but the AGENTS.md contract is broader — "PortOS must never queue up AI provider calls a user hasn't knowingly triggered", by any route. A future read path reaching a provider through a direct fetch, a streaming helper, or a second transport would slip past the export-level spy while it stayed green. Restore a global fetch spy alongside it so the assertion sits back at the wire. Nothing anywhere pinned the actual bug atomantic#5617 describes: both generators persist unconditionally once result.error is unset, so a transport that misclassifies a failed call as a successful empty completion overwrites a real cached theme/narrative with nothing. That was only proven transitively — the shared transport returns an error, and you read the `if (result.error) return` guard at each call site. Assert it at the Insights boundary instead: an errored transport result surfaces as { available: false, reason } and leaves atomicWrite untouched, for both generateThemeAnalysis and refreshCrossDomainNarrative, plus the positive case so the guard can't pass by never writing at all. Verified the two guard tests are not vacuous: neutering both `if (result.error)` guards in insightsService.js fails exactly those two and nothing else.
Summary
insightsService.jsdefined a secondcallProviderAISimple, behaviorally divergent from the canonicalserver/services/aiProvider.jsone: it classified a non-JSON/blank 200 body as an error (correct) but a valid 200 body with empty content as a successful{ text: '' }— the opposite ofaiProvider's classification, which treats a whitespace-only completion as a provider error.generateThemeAnalysisandrefreshCrossDomainNarrativepersist their result unconditionally onceresult.erroris unset, so the divergent copy could overwrite a real cached narrative/theme with nothing on an otherwise-valid-but-content-empty response.aiProvider.callProviderAISimple— same call shape, drop-in compatible (also incidentally fixes a smaller divergence: the deleted copy calledensureOllamaProviderReadyfor any API-type provider, not just Ollama-backed ones, unlike the shared implementation'sisOllamaProvidergate).aiProvider.test.js(where the shared implementation now lives, and where its whitespace-only-completion test already lived).insightsService.test.jskeeps only the disk-only-read-path contract, matching its own docstring's stated scope.Test plan
npx vitest run services/insightsService.test.js services/aiProvider.test.js— 24 tests passcallProviderAISimpleexport frominsightsService.js(the route file usesimport * as insightsService, unaffected)insightsService.js— only the route file and its own testFixes #5617