feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI) - #36641
feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI)#36641fmontes wants to merge 147 commits into
Conversation
Tell the model that binary file-asset endpoints (e.g. /api/v2/assets,
/dA) return a { __dotcmsBinary, contentType, base64, byteLength }
envelope whose base64 is the raw bytes to decode — not text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Master plan (a11y-agent-plan.md) and per-session briefs (S0-S5) for the dotCMS accessibility-fix agent. Includes the S0 spike outcomes: the loop composes through @dotcms/agentic-tools, the minted JWT is accepted on all four endpoints, and the EDIT_MODE-vs-EDIT_MODE re-scan basis. Captured real response shapes in S0-captured-responses.json as the reference S1 codes the report schema against. Gitignore .env and the scratch/ throwaway. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic agents-host service (core-web/apps/dotcms-agents), sibling to mcp-server; the a11y-fix agent is its first capability (S1). @nx/node application, framework=none, esbuild/cjs, jest, eslint. Hono + node-server for the HTTP surface (no Nx plugin needed — Nx bundles/serves the TS entry, Hono is a plain import). Sets moduleResolution=node in tsconfig.app.json (the base bundler resolution is incompatible with module=commonjs). Verified: build succeeds and GET /health returns ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The §6 report and §8.2 request schemas — the seam S2 (proxy) and S3 (Studio) build against. Statuses locked to the five-value vocabulary (fixed-to-working | reported | skipped | regressed | failed); publishRequired is z.literal(true) so the agent can never report a publish. Tests validate the §6 plan example verbatim and assert the locks (status set, hostId required, publishRequired true, non-negative counts). Also the §8.7 active-run slot schema. contract.ts at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…§3-B) withAllowlist() wraps the agentic-tools api adapter so only the four loop operations reach the wire: page-scanner/a11y/check POST, _render-sources GET (prefix), /api/v2/assets GET, /api/v2/assets/save PUT. Everything else — publish, delete, workflow, config — is rejected before fetch, even under prompt injection. The /save vs /publish distinction is enforced by exact-match (a prefix rule would admit /publish). The wrapper never sees the auth token (it lives in the inner execute's closure). 16 tests incl. the DoD publish-path rejection and proof the token-bearing inner is never reached on a disallowed call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runFix() drives SCAN(live) → SCAN(EDIT_MODE baseline) → LOCATE → READ → per-violation TRIAGE → FIX → SAVE-WORKING → RE-SCAN → REPORT. Shape (B): the LLM is scoped to two structured calls (triage+attribution, minimal diff via AI SDK generateText+Output.object); all sequencing, guards, caps and §6 report assembly are plain code so the guards are testable paths. Guards (each tested): refuse-if-dirty (working≠live → skipped, no save), attribution-evidence gate (no edit unless the read file contains the offending markup), auto-revert-on-regression (re-scan worse than the EDIT_MODE baseline → revert + regressed), 0-byte save → failed, per-run caps (files/bytes/violations). Re-scan basis is EDIT_MODE-vs-EDIT_MODE (S0: chrome adds phantom violations). DotcmsClient wraps the 4 calls through the allowlist-guarded sandbox; triage/fix are injectable for tests. 36 tests green, typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the loop to the front door (plan §8.2/§8.7). POST /a11y/fix reads the token from Authorization: Bearer (never the body), validates against the locked FixRequestSchema (401 no-token / 422 bad-body / 502 run-failure), runs runFix, returns the §6 report as JSON. GET /a11y/active-run returns the calling user's slot. Per-user ActiveRunRegistry keyed by the JWT sub claim (decoded, not verified — verification is the proxy's job, S2); stale-run finishes don't clobber a newer slot (replace-on-retrigger). Build switched to bundle:true so the agentic-tools spec.json is inlined (unbundled output failed to require the generated json at runtime). Verified live: health ok, 401/null/401/422 on the endpoints as expected. 13 new tests (auth helpers, routes, registry); 49 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2e against the real demo /index surfaced two issues: 1. refuse-if-dirty misfired on the agent's OWN in-run edits — after the first fix to a file, the next violation in that same file saw working≠live and skipped, cascading to 5 false skips. Removed the guard entirely: the goal is to fix the a11y issue, and working-save is non-destructive (dotCMS per-asset version history, §3). The loop now keeps one progressively- improved working copy per file (currentContent); later violations build on earlier edits. Plan §5/§6/§12 updated to record this as an accepted v1 tradeoff (concurrency safety remains GA debt). 2. CSS contrast — the most common violation class — was unreachable: LOCATE only surfaced VTLs, so every contrast issue reported "rule lives in styles.dotsass, not a candidate." _render-sources now returns theme.css + theme.js; collectCandidates includes them. Re-run read 13 files (was 9) and generated a fix directly in styles_precompiled.css. Verified live: dirty cascade gone (2→5 real fixes), CSS source reached. API-error path also confirmed (out-of-credits → failed status with reason, no crash). 49 tests green; refuse-if-dirty test replaced with a same-file- builds-on-previous-edit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opus cost ~$12 for a single page in e2e testing — far too expensive for triage classification and minimal diffs. Two cost fixes: - Default model Opus 4.8 → Sonnet 4.6 (~5x cheaper), overridable per deploy via A11Y_AGENT_MODEL with no code change. Model stays injectable per call. - Prompt-cache the triage candidate-files block. It is identical for every violation in a run but was re-sent in full each time (the dominant cost — 13 files incl. large CSS, x20 violations). Moved it to a cacheable ephemeral user message; the per-violation details go in a separate uncached message. ~10x cheaper on the repeated prefix after the first call. Together these should bring a page from ~$12 toward ~$1. 49 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setUsageSink() lets tooling total token usage (input/cached/output) across every triage + fix call in a run without threading usage through signatures. Unset in production (zero overhead); the e2e harness registers it to log per-call tokens and estimate run cost. Both generateText calls now report their usage through it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
collectCandidates no longer includes theme JS. The agent doesn't edit JS, and theme JS bundles are large (on the demo theme core.min.js alone is ~249K chars / ~62K tokens) and dominated triage token cost. JS-injected DOM issues are still surfaced — handled via report-only triage. Candidate set on the demo /index drops 13→11 files, ~64K tokens (~33%) off every triage call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…itelist The theme block split files into hardcoded vtls/css/js buckets, which misses the many other extensions a theme legitimately uses (.scss, .sass, .dotsass, .less, ...). Whitelisting types in the API is the wrong layer. Return every file under the theme folder in a single files[] list, each carrying its lowercased extension, and let consumers filter by type. buildThemeView no longer matches on extension at all; FileRefView gains an `extension` field (also populated for widget file refs). a11y-agent side: collectCandidates now keeps theme files whose extension is in an editable set (vtl + stylesheet preprocessors) and the save-content-type helper treats all stylesheet extensions as text/css — so .scss/.sass/.less are picked up automatically with no further code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dated) Sending whole stylesheets to the LLM cost ~$1-12/page and doesn't scale. A spike validated deterministic attribution: parse CSS (postcss) + match the offending element against rule selectors (css-select) + rank by specificity, sending the model only the winning rule(s) — 34,007 → ~106 tokens (99.7%), scale-independent. Two residual constraints captured: sound matching is pure-compound-only (the scan gives the element, not ancestors), and fixes must edit the SCSS source, not the compiled artifact (regenerated on compile). - New session brief S1.5-css-attribution.md (module + wire-in + compiled→SCSS mapping + sound-matching guard + contrast-math option). - README: S1.5 in table + dependency graph; replaced the stale refuse-if-dirty convention (removed in S1) with the no-whole-CSS / edit-source conventions. - Plan: new §3 "CSS attribution" decision row; §5 TRIAGE/READ/FIX updated; §9 risk entry (validated, with the two constraints); §10 Phase 1 step 4b; status line reflects S0/S1 done, S1.5 spiked. Spike artifacts live in core-web/scratch/ (gitignored): SPIKE-css-attribution.md + css-attribution-proto.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
defaultModel() now picks the provider from env (plan §3 — provider not locked, no loop rewrite to swap): A11Y_AGENT_PROVIDER = anthropic (default) | openrouter A11Y_AGENT_MODEL = provider-appropriate model id OPENROUTER_KEY / OPENROUTER_API_KEY = key when provider=openrouter Uses @openrouter/ai-sdk-provider (createOpenRouter().chat(model)). Verified live: anthropic/claude-sonnet-4.5 via OpenRouter returns valid structured output (Output.object path the loop relies on); OpenRouter also reports per- call cost in usage.raw.cost. Default behavior unchanged (Anthropic Sonnet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switches the default provider/model to OpenRouter + kimi-k2.7-code. Structured output (Output.object) verified working through it, and it's ~11x cheaper per call than Sonnet on the triage/diff workload ($0.00008 vs $0.0009 on a smoke call). Still env-overridable (A11Y_AGENT_PROVIDER / A11Y_AGENT_MODEL); anthropic path unchanged when selected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… provider The triage file-context part hardcoded providerOptions.anthropic.cacheControl, which is meaningless on OpenRouter and can trip its response parser. Gate it on DEFAULT_PROVIDER === 'anthropic'. (Native-Anthropic prompt caching unchanged.) Note: this is not what blocks the OpenRouter e2e — that fails because the loop still sends the whole theme source tree (154 files ≈ 217K tokens) per triage, over Kimi's context limit. The provider itself is verified working (smoke + small real triage call succeed); the fix is S1.5 CSS attribution / not sending all files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real themes have 150+ SCSS partials (demo: 154 editable files ≈ 217K tokens). The crux isn't model context size — handing a model the whole tree is the wrong operation at any size (cost, accuracy, speed, doesn't generalize); it's now a hard failure on tighter-context models (Kimi: "Provider returned error"). Lock the loop to be LAZY and per-violation — NEVER pre-read the theme tree: attribute against the one compiled stylesheet → winning rule (~100 tokens, the only thing the LLM sees) → sourcemap (now shipped/validated) → read only the one SCSS source file → edit. Partial count becomes irrelevant (~1 stylesheet + ~1 source file per fix). - S1.5 brief: new "Core principle — NEVER pre-read the theme tree" section; tasks reordered (rip out collectCandidates pre-read; lazy CSS path; sourcemap resolution now that the endpoint is live); entry state updated (sourcemap shipped, deps, OpenRouter+Kimi default). - Plan §5 READ step rewritten as lazy/per-violation with the partial-count rationale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n validated
Scanner now returns applied stylesheets[] at the scan-response root (option B),
so the agent picks the compiled stylesheet from what the page actually loaded
(filter same-origin, drop CDN/fonts) — no name-guessing, handles multiple
sheets/any extension.
Validated the FULL lazy chain live, end to end, zero partials read, no LLM:
scan.stylesheets → styles.dotsass → fetch ?sourcemap=true → postcss+css-select
for #book → .button-primary{background-color:#e76300} → sourcemap value-column
→ custom-styles/_variables-custom.scss:64 = $primary:#E76300.
Also captured the sourcemap-extraction gotcha (URL-encoded payload contains '*',
so a non-greedy regex breaks; use indexOf marker → first comma → last '*/' →
decodeURIComponent). S1.5 brief + sourcemap spec updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-screen Accessibility Studio portlet at libs/portlets/dot-accessibility-studio, registered at /accessibility-studio. - Page picker: real /api/content/_search via DotContentSearchService (host-scoped pages + urlmaps, debounced search, p-table pagination). - Studio run screen: score widget, agent recipe log, state-driven action footer (scan/fix/publish/discard), iframe preview. Run is MOCK data based on the agent §6 FixReport contract — no SSE/overlays/animation yet (S4/S5). - SignalStore drives the phase state machine (picker→ready→scanning→scanned→fixing→done→published). - PrimeNG + Tailwind, dotCMS primary token, i18n keys, data-testid. - Tests: 36 passing (store query/search/state machine, picker, run screen). Menu guards temporarily removed from the route for local iteration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oundation) Two pure, deterministic modules (no LLM, no network) — the building blocks of the lazy CSS-fix path. Built in parallel, hardened from the validated spike. css-attribution.ts: - parseColorRules(css) → color rules (postcss AST), keeps the node + position - attribute(elementHtml, rules) → matching rules ranked by specificity - SOUND matching only: pure compound selectors (combinator selectors excluded — the scan gives the element, not its ancestors; rightmost-compound fallback false-positives). Dynamic pseudos stripped for the match, kept in output. css-source-map.ts: - extractInlineSourceMap(css) → parsed v3 map; robust extraction (indexOf marker → first ',' → last '*/' → decodeURIComponent) handling the literal-'*'-in- payload gotcha - resolveSource / resolveDeclarationValue → map a compiled decl's value column back to its SCSS source file+line (lands on the $variable) jest.config.cts: transformIgnorePatterns whitelist for the pure-ESM deps (css-select/htmlparser2 + transitive) so their specs run under ts-jest. 24 new tests; 73/73 pass, tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The loop no longer pre-reads the theme tree. processViolation now routes: - CSS (color-contrast): deterministic path — pick the applied stylesheet from scan.stylesheets[] → fetch the ONE compiled sheet (+inline sourcemap) → attribute the rule in code (css-attribution) → map the decl's VALUE column back to its SCSS source via the sourcemap (lands on $primary in _variables-custom.scss) → LLM sees ONLY the matched rule (~300 tokens) → edit that one source file. The 150+ SCSS partials are never read or sent. - VTL: small candidate set (theme + container VTLs), read lazily, LLM triage+fix. saveAndRescan() shared by both (save-working, verify bytes, EDIT_MODE re-scan, auto-revert on regression). New: client.fetchStylesheet (compiled CSS+map, absolute→relative URL), allowlist entry for GET /application/themes/ (read-only theme assets), triage.generateColorFix (rule-scoped color nudge). Resolves the decl VALUE position (not the rule/selector position, which traces to the mixin) and edits the real source token (#E76300), not the compiled value (#e76300). Verified live on demo /index with OpenRouter+Kimi: per-fix payload ~298 tokens (was 217K → "Provider returned error"), fixes resolve to _variables-custom.scss and land; auto-revert correctly fires on a shared-token regression. 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion) A single shared-rule CSS edit clears many violations, so the loop now reuses the ONE re-scan saveAndRescan already does (no extra scan per violation) to refresh which violations remain, and skips collateral-cleared ones. Honest reporting: a vanished violation is only credited 'fixed-to-working' if we actually edited a source for that SAME rule code; otherwise 'reported' (scan variance), so we never over-claim. Also cap maxOutputTokens (Kimi over-generated to 24k on a one-line color fix, stalling runs): triage 2048, color-fix 2048, file-fix 8192. Validation status: machinery proven end-to-end (attribute → sourcemap → correct SCSS file → surgical edit → save → re-scan; ~48s, ~$0.002). KNOWN GAP: fixes attribute .btn/button contrast to a generic `a:focus` rule and don't actually clear the violation (scan count unchanged) — an attribution- accuracy problem to debug next, not a wiring problem. 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scanner (axe) measures contrast in the element's RESTING state, but
attribution was stripping :hover/:focus/:active and then MATCHING those rules
— so `a:focus` outranked the real resting `a`/`.btn`/`.button-primary` rule
and the agent edited a state that doesn't apply (fixes never cleared the
violation). Now any selector with a state pseudo-class or pseudo-element
(:hover/:focus/:active/:visited/:target/:focus-*/::before/::after/…) is
EXCLUDED from matching.
Verified against the real theme: <a class="btn"> now attributes to
`a { color }` (resting), <a class="button-primary"> to `.button-primary`
(ranked above `a`) — no more :focus mis-attribution. 73 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agent)
The scanner now returns a pure axe result (axe.{violations,incomplete,…} +
stylesheets) and no longer carries findings/counts — which the agent read, so
the agent was out of sync with the live scanner. Per the "agent owns
normalization" decision, DotcmsClient.scan() now maps raw axe → the internal
ScanResult: each axe violation RULE expands to one finding per flagged NODE
(contrast rule w/ 14 nodes → 14 findings), incomplete → needs-review, and
crucially the per-node check `data` (fgColor/bgColor/contrastRatio/
expectedContrastRatio) is carried onto each finding. passes/inapplicable are
ignored. The rest of the loop is unchanged (ScanResult shape preserved).
This unblocks deterministic contrast fixing next: with finding.data the agent
can attribute by exact fgColor and compute the WCAG nudge in code (no LLM).
6 normalizeAxe tests; 79 total green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the scanner returning axe's per-node data (fgColor/bgColor/ratio/target), the contrast fix is now pure WCAG math instead of an LLM call — removing Kimi from the contrast path (it returned empty/runaway output on the structured color fix) and making each fix mathematically guaranteed to clear. New contrast.ts (no dependency): parseColor, relativeLuminance, contrastRatio, parseTargetRatio, nudgeToClear (binary-search the minimal hue-preserving lightness nudge; evaluates the ROUNDED hex so 8-bit quantization can't land just under threshold; returns null when unreachable → reported). processCssViolation: attribute the rule → resolve the SCSS source via sourcemap → take the editable color (attributed decl value) + its counterpart from finding.data → nudgeToClear → replace the source token → save → re-scan. The diff records before/after ratio. generateColorFix LLM path removed from runFix. 13 contrast tests; 89 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecificity The CSS path picked the editable decl by specificity ranking, then paired it with axe's fg/bg by property name — which mis-paired when the attributed rule's color didn't match what axe actually measured (e.g. nudging a stale `a` color against an unrelated blue bg → "cannot reach 4.5:1"). Now axe's data is the source of truth: among the element's matched rules, pick the (rule, decl) whose value EQUALS axe's fgColor or bgColor; the other color of the pair is the counterpart. If no matched rule's color equals the flagged pair, report honestly (the failing color is inherited/inline/computed, not in an attributable rule) rather than edit the wrong thing. findNamedColorDeclNode resolves the exact decl node (a rule may have several color decls). 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…MODE The studio preview iframe now loads the selected page same-origin through the `/dot-page` dev-proxy sentinel (apps/dotcms-ui/proxy-dev.conf.mjs strips the prefix and forwards to the BE page renderer), instead of pointing at the Angular dev server origin. - previewUrl → `/dot-page<path>?host_id=&language_id=&mode=EDIT_MODE` (§8.2 working-version render the agent re-scans). - Thread the host identifier (StudioPageRow.hostId from contentlet.host) so host_id disambiguates which site's copy of the path renders. - Add the `/dot-page` proxy rule for the iframe. - Tests updated; 36 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The external scanner now returns raw axe-core data (axe.violations / axe.incomplete) instead of the normalized findings/issues envelope. Remodel the service types, map rules to display groups (one rule per group, nodes as items), derive error/warning counts in the component, and drop the now-nonexistent "notices" summary card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…allback) When the editable color is the foreground white text, nudging it can't clear against a light background (white is already maximal) — the agent gave up. Now it builds BOTH candidate edits (the decl matching fgColor → nudge vs bg, and the decl matching bgColor → nudge vs fg) and tries them in order, keeping the first that actually yields a fix. So white-text-on-light-bg now falls back to darkening the background instead of reporting unfixable. Still exact-color-match only (no guessing); reports honestly if neither side is nudgeable/reachable. 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tput Kimi K2.7 returned empty/malformed structured output on the triage schema (4/14 violations failed as "No output generated"). Switched the default OpenRouter model to z-ai/glm-5.2 — reliable structured output, good triage reasoning, low cost (verified). Also added withRetry() around the structured generateText calls: LLMs intermittently return empty/unparseable objects even on tiny prompts (a transient hiccup), so retry up to 3x on exactly those errors (No output generated / did not match schema / could not parse) before giving up; real errors still rethrow immediately. Model still env-overridable via A11Y_AGENT_MODEL. 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| <div | ||
| class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs" | ||
| data-testid="velocity-playground-error-detail"> | ||
| @if (detail.errorType) { |
There was a problem hiding this comment.
This errorType badge is a hand-rolled span with hardcoded severity coloring (bg-red-100 / text-red-700). Since it's a read-only status indicator, would <p-tag severity="danger" [value]="detail.errorType" /> be preferable, so the color comes from the theme preset rather than a literal Tailwind pairing?
There was a problem hiding this comment.
Fixed in 09ee788fb7 → <p-tag severity="danger" [value]="detail.errorType" styleClass="font-mono" />. Kept the mono face since it renders an error type token; the color now comes from the theme preset.
| <li | ||
| class="flex flex-wrap items-baseline gap-x-2" | ||
| data-testid="velocity-playground-warning-item"> | ||
| <span |
There was a problem hiding this comment.
Same as the error badge on the page component: this warning.type span hardcodes bg-yellow-100 / text-yellow-800. Would <p-tag severity="warn" [value]="warning.type" /> be a better fit for a read-only status label?
There was a problem hiding this comment.
Fixed in 09ee788fb7 → <p-tag severity="warn" [value]="warning.type" styleClass="font-mono" />, same shape as the error badge.
| firstValueFrom(service.run<DemoResult>('/url', {}).pipe(toArray())) | ||
| ).rejects.toThrow(/Agent request failed \(500/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
I don't see a test that unsubscribes from run() mid-stream and asserts the request is actually aborted.
That teardown (() => controller.abort()) is the only thing stopping an in-flight agent stream when the user navigates away or restarts a run, so a refactor that dropped the signal from fetch or moved the abort call would leak requests with this suite still green. Worth asserting fetchMock.mock.calls[0][1].signal.aborted after unsubscribing?
| expect(events).toEqual([{ type: 'error', message: 'Agent run failed.' }]); | ||
| }); | ||
|
|
||
| it('errors the observable when the response is not ok', async () => { |
There was a problem hiding this comment.
The failure coverage here stops at the ok: false response. There are two other error paths in the service - fetch itself rejecting (network drop before any response) and reader.read() rejecting mid-stream after some events have been emitted - each with its own try/catch calling subscriber.error.
Would be worth covering both, since a regression that swallowed either would turn a dropped connection into a silently stalled stream.
| ]); | ||
| }); | ||
|
|
||
| it('handles frames that straddle chunk boundaries', async () => { |
There was a problem hiding this comment.
The chunk-boundary tests here are good. Is there also a case for a frame whose data: line isn't valid JSON?
#parseFrame deliberately catches and returns null to drop it, but nothing pins that down - a change that let the parse error propagate would take down the whole stream on one bad frame from a flaky backend, and this suite would still pass.
| @@ -0,0 +1,108 @@ | |||
| import { includeMatcher, splitIncludePatterns } from './assets-transfer'; | |||
There was a problem hiding this comment.
This spec covers splitIncludePatterns and includeMatcher only, but the production diff for assets-transfer.ts is +214/-35 and reworks real transfer behavior that has no coverage here: the 0-byte skip being removed (files now upload as-is), the new retry-with-'\n' fallback, the new final re-check pass in verifyLive that fixes a false-negative, and the new totalSeen / zero-match warning branch. Neither uploadAssets nor downloadAssets is invoked with a fake runtime.
Would it be worth adding a few tests around those paths, given they're the behavior changes the rework is for?
| expect(store.beforeCount()).toBe(5); | ||
| }); | ||
|
|
||
| it('captures the run id from the stream and targets stop at it', () => { |
There was a problem hiding this comment.
stopAgent() swallows a failed /stop via catchError(() => EMPTY), but every test here drives agentService.stop through the success mock. Would a case with throwError be worth adding, to pin down that a failed stop leaves the store in a sane phase and doesn't take down the stream?
| expect(store.afterCount()).toBe(MOCK_FIX_REPORT.scan.after.violations); | ||
| }); | ||
|
|
||
| it('progress events drive the live openCount down while fixing', () => { |
There was a problem hiding this comment.
rescanPreviewDuringFix() fires on every progress frame and swallows scan failures via catchError(() => EMPTY), but the progress tests only exercise the success path.
Worth a case where the mid-fix rescan errors, asserting the phase stays fixing and previewRevision doesn't advance? That's a named failure mode in the code with no coverage.
| @@ -0,0 +1,110 @@ | |||
| import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest'; | |||
There was a problem hiding this comment.
onLazyLoad() converts the PrimeNG table event into a 1-based page number with Math.floor(first / rows) + 1 and calls store.setPagination, but nothing in this spec exercises it.
Off-by-one page math tends to regress quietly - worth a test asserting { first: 25, rows: 25 } maps to page 2, plus the first: 0 boundary?
| }); | ||
| }); | ||
|
|
||
| describe('marker visibility (showMarkers)', () => { |
There was a problem hiding this comment.
These marker-visibility tests assert on the showMarkers() computed, but the behavior that matters is the effect calling markerService.render(...) with show ? groups : [] for each of the two frames. A11yMarkerService is mocked and never asserted on.
Would asserting the render calls catch more? An inverted ternary or the wrong groups passed to the wrong frame would currently pass.
|
|
||
| /** The tone accent dot-color-icon resolved onto its host custom property. */ | ||
| const chipColor = () => | ||
| spectator |
There was a problem hiding this comment.
The assertions here select by CSS class and tag (.material-symbols-outlined, dot-color-icon, .w-0\.5, .pi-spinner) rather than byTestId, and the template has no data-testid attributes.
Since those are styling and icon-font details, a purely visual refactor would break these tests without any behavior change - worth adding testids to the icon, connector and chip elements?
|
|
||
| it('renders a spinner + gradient-shimmer label', () => { | ||
| // Spinner is the clear motion cue… | ||
| expect(spectator.query('i.pi-spinner')).toBeTruthy(); |
There was a problem hiding this comment.
Same note: these select on i.pi-spinner and .agent-shimmer rather than byTestId. Adding data-testid to the spinner and shimmer label would decouple the tests from the icon library and the local shimmer class name.
| sites?: Array<{ | ||
| identifier: string; | ||
| hostname: string; | ||
| isDefault: boolean; |
There was a problem hiding this comment.
The splitUrlPath coverage here is thorough on percent-decoding, ./.. collapsing and query/fragment stripping. One case that looks absent: a path beginning with //.
new URL('//books/index', 'http://_').pathname is /x-style scheme-relative parsing and returns /index, silently discarding the first segment as a host - so //books/index would resolve to folder / rather than /books. Worth pinning down the intended behavior (error vs documented equivalence), given this function exists to prevent silent misplacement?
There was a problem hiding this comment.
Good catch, and it was worse than a missing test — //books/index really did resolve to /books... no: it resolved to /index. Fixed in cf139e1313.
To the URL API a leading // is scheme-relative, so new URL('//books/index', 'http://_').pathname parses books as a host and returns /index — the first segment silently discarded, a page path resolving to a different page. /// did not parse at all. Leading slashes are collapsed before parsing now, which is a real concern rather than a theoretical one, since //host/path is how dotCMS writes a host-qualified path elsewhere.
This also turned out to affect more than splitUrlPath: page_verify and page_place_content were interpolating the raw caller path straight into the request URL with no normalization at all. That is now shared in a new lib/page-path.ts — details in the top-level comment.
| } | ||
|
|
||
| /** Package-private constructor for unit tests. */ | ||
| A11yAgentResource(final WebResource webResource, final HttpClient httpClient) { |
There was a problem hiding this comment.
This package-private constructor is here for unit tests, but there's no test file for this resource anywhere in the repo.
Given the class relays SSE, mints tokens, and reads App secrets, would some coverage of the config-resolution and error-relay paths be worth adding while the seam is fresh? This is currently the only acceptance criterion on the issue with no automated test behind it.
There was a problem hiding this comment.
Done in 99b54608a2 — A11yAgentResourceTest, 12 tests, using the package-private seam and mirroring PageScannerResourceTest.
Covers: App not configured and half-configured (missing apiUrl or apiAuthToken) across all four endpoints; request validation; that a malformed request mints no token and never touches App secrets; that /fix/stream reports a config failure in-band with a closed stream; and that the App auth token never appears in an error body — the proxy is the auth boundary, so a relayed error is where a secret would most plausibly escape.
Config resolution and the error-relay paths are covered; the happy-path forward still is not, since that needs a stubbed HttpClient exchange.
| children: [ | ||
| { path: '', component: DotA11yPageListComponent }, | ||
| // Wildcard, not `:id`: the run URL carries the page's human-readable | ||
| // path (e.g. `blog/post/hello`), which is multi-segment and so can't be |
There was a problem hiding this comment.
This comment says the wildcard route captures the path and "the run screen reads it back and rehydrates the page", but the run component reads the row from location.getState() and bounces back to the list when it's absent - its own comment notes the URL "can't supply identifier/host/language".
Since the two comments disagree, someone reading this file would reasonably conclude deep-linking to a run works. Worth rewording to match the actual bounce behavior?
There was a problem hiding this comment.
Fixed in 09ee788fb7. You were right that the two comments disagreed, and the route comment was the wrong one — the URL is display and history only.
Reworded to say so explicitly: the page is handed over in navigation state because the path alone cannot supply identifier, host and language, so a cold load (new tab, refresh, pasted link) has no state and bounces to the list.
Consolidated review: frontend and MCP serverThis single comment replaces 45 inline threads plus 11 shorter notes of my own, removed to keep the PR readable. Everything is here with Scope: TypeScript only. Organised around the four themes that recur across this PR:
Deliberately out of scope: the Java backend, 1. Error serializationThe test applied at every throw site: given only this error string, can the calling model fix the call and retry, without asking a human and without guessing? For an MCP tool a vague error is a functional bug, not a cosmetic one.
** ** ** ** Minor, same class:
2. Missing try/catchThe damage from an unguarded ** ** ** ** ** ** ** ** 3.
|
| # | Site | Iterates | Committed state on mid-loop failure | Class |
|---|---|---|---|---|
| 1 | assets-transfer.ts:112 |
remote assets to local disk | Yes (files written), but download at :80-90 already try/catches per item |
3, bounded concurrency |
| 2 | assets-transfer.ts:207 |
local files to dotCMS uploads | Yes (assets created); per-item try/catch at :208-223 |
2, sequential |
| 3 | assets-transfer.ts:252 |
_search pages |
No (precedes writes) | Inherently sequential (offset depends on the prior page); its problem is the missing bounds |
| 4 | assets-transfer.ts:356 |
up to 3 verify rounds | n/a | Inherently sequential (round N+1 consumes round N's notLive) |
| 5 | assets-transfer.ts:359 |
pending to isLive GETs |
No (pure reads) | 1, allSettled |
| 6 | assets-transfer.ts:369 |
notLive to PUBLISH fires |
Yes (publishes fired) | 2, sequential |
| 7 | assets-transfer.ts:384 |
pending to final isLive GETs |
No (pure reads) | 1, allSettled |
| 8 | assets-transfer.ts:419 |
local dir walk (readdir recursion) |
No (pre-write) | Leave sequential: local FS, parallel recursion risks EMFILE for no gain |
| 9 | sdk/ai/src/adapter/request-core.ts:374 |
formData fields |
No (throws before fetch) |
Leave sequential: 1 to 3 fields, and form.append order should stay deterministic |
Category 1, allSettled (#5, #7). Pure GETs on distinct identifiers with no interdependence. Cost today for a 120-file theme: up to 3 × 120 sequential isLive GETs plus the 120 of the final pass, so up to 480 sequential round trips, of which only the last round's results matter. Largest latency win in the file, and allSettled also supplies the per-item isolation these two currently lack, since a rejected read today throws out of verifyLive entirely.
Category 2, sequential plus per-iteration try/catch (#6, #2). #6 fires workflow actions against content dotCMS is concurrently versioning and indexing, so keep it sequential. The per-item reporting is the fix, not the concurrency: a per-item failure becomes a per-item manifest entry instead of a thrown exception. #2 is the same category less obviously: uploading to /api/v2/assets implicitly autocreates the parent folder tree, so two in-flight uploads sharing a not-yet-existing parent race on folder autocreation. Only safe if grouped by folder or with folders pre-created, which is not worth it.
Category 3, bounded concurrency (#1). Genuinely independent, but the array is unbounded (enumerateAssets has no cap, and /application can enumerate tens of thousands). allSettled over that is one simultaneous GET and one open file handle per asset, so socket exhaustion and EMFILE. A realistic theme is 100 to 500 files, so a cap of 5 to 8 captures nearly all the win.
No helper exists. Searched core-web/libs, core-web/apps and the package manifests for p-limit, p-queue, p-map, promise-pool, mapLimit, parallelLimit, withConcurrency, and any exported chunk/batch/pool util: zero hits, and no such dependency declared. So this is a roughly 12-line local helper (preferably in lib/runtime.ts, which already owns cross-tool plumbing) or a new dependency. If you write one, note on the line that its inner Promise.all is safe only because each worker catches everything and never rejects, otherwise someone will later "simplify" it into the fail-fast form this whole inventory argues against.
Related concurrency defects outside the loops
mcp-server/src/lib/assets-transfer.ts:216 (code at :252) — MEDIUM. Unbounded pagination, including a path that never terminates.
No max-pages guard, no max-assets guard, no "offset made no progress" check; the only exit is :279.
Scenario A: download_assets on /application of a large site returns 40k assets, all accumulated into assets[] then downloaded one at a time, with no cap on either.
Scenario B: if the _search body's offset is ignored or clamped by the backend, page.length is always SEARCH_LIMIT, the exit never fires, seen de-dupes so assets stops growing, and the loop spins forever issuing identical POSTs. Combined with the missing request timeout from §1, nothing ever breaks the cycle: the MCP call hangs indefinitely and the instance takes sustained identical-query load.
Fix: cap total assets and pages, and break when an iteration adds no new identifiers to seen. That one check kills scenario B regardless of backend behaviour.
**.../a11y/store/a11y-page-list.store.ts:127 — MEDIUM. Overlapping searches are never cancelled.** No SubscriptionSlot, no switchMap, no takeUntilDestroyed, no onDestroy. *Failure:* type "blog" then click page 2. Two _searchPOSTs; if page 1 resolves last it overwritespagesandtotalRecordsand setsloadedwhile the paginator shows page 2, so table and paginator disagree and a row click opens a page the user did not select. It also leaks: navigate away mid-search andpatchStateruns against a destroyed store. *Fix:*switchMap, or the SubscriptionSlotthe run store already uses, pluswithHooks({onDestroy}), mirroring a11y-run.store.ts:691-693`.
**.../a11y/a11y-diff/a11y-diff.component.ts:114 — MEDIUM. The diff refetches per SSE frame with no supersession.** Keyed on previewRevision(), which is bumped on **every** progress frame (a11y-run.store.ts:410-413). loadDiff() (:131-157) has takeUntilDestroyedbut nothing supersedes the previous load, andp-accordion-contentusesmountOnEnter=falseso it refetches even while collapsed. Per frame that is one_render-sourcescall plus two per source file, so dozens of overlapping requests per frame. *Failure:* a stale response writes the olderfiles.set()andchangedCount.emit(), so the Files badge goes stale and, because the Publish bar is gated on changedFileCount, hasChangedFiles()` can flip back to false after the agent wrote files, blocking publish until request ordering happens to favour it.
.../a11y/store/a11y-run.store.ts:392 (called from :595) — LOW-MEDIUM. A full axe rescan fires on every progress frame.
SubscriptionSlot.set aborts only the client HTTP request; the backend headless render already launched and keeps running. A chatty agent emitting progress every few seconds queues many concurrent full-page renders per run.
Shared root cause with the finding above: previewRevision is too hot to be a refetch key. Debounce it, or rescan only on terminal and rate-limited frames.
4. Type duplication
**.../a11y/models/a11y-groups.ts:13 — MEDIUM, the one to fix first. Three types and two functions copied from edit-ema, and the copy has already drifted.** A11yFindingType :13, A11yGroupItem :16, A11yGroup :24, buildA11yGroups :39andmapRules :50mirroredit-ema/.../dot-page-scanner-report/models.ts:9/:14/:24anddot-page-scanner-a11y-report.component.ts:48/:61. The header at :5-7is explicit that this is a replication because edit-ema'smodels.tsis not exported. *The drift is already here:*A11yGroup.impactisAxeImpactin edit-ema butAxeImpact | nullhere, andAxeImpactalready includesnull, so the | nullis redundant and signals the author was unsure which contract was authoritative. Same redundancy atmodels/a11y-severity.ts:45. *It is already costing you:* the target.joinbug (§5) exists in **both** copies and has to be fixed twice. *Fix:* the blocker named in that header comment is a one-line change. Addexport * from './lib/dot-page-scanner-report/models';toedit-ema/ui/src/index.ts(precedent atindex.ts:15). This file already imports AxeImpact, AxeRuleandPageScannerA11yResponsefrom@dotcms/portlets/dot-ema/uion line 1, so **no new dependency edge is created**. Then delete the local types, movebuildA11yGroupsandmapRules` next to the shared models, and have the edit-ema component call the shared function.
**mcp-server/src/lib/page-create.ts:194, :201, :187 — three local types duplicate types already in scope.** ContentTypeSummaryis re-exported from@dotcms/ai/runtime (sdk/ai/src/runtime.ts:233-240), **the module this file already imports on line 1**, and it is the actual type of the context.contentTypesthe file already consumes at:248, :263and:270. So the file reads a ContentTypeSummary`, then hand-copies three of its six fields into a parallel local interface.
type ContentTypeDefinition = Pick<ContentTypeSummary, 'id' | 'variable' | 'baseType'> & { fields: ContentTypeField[] };
type PageEntity = Partial<Pick<DotCMSBasicContentlet, 'identifier' | 'inode' | 'live'>> & { contentlets?: PageEntity[] };Pick rather than extends on purpose: fetchContentTypeDefinition never populates name, so inheriting it would be a lie. DotCMSBasicContentlet lives at sdk/types/src/lib/page/public.ts:353, and @dotcms/types is a pure-type entrypoint, so it resolves at zero runtime cost.
For the field half, DotCMSContentTypeField (dotcms-models/src/lib/dot-content-types.model.ts:540) already has all four fields: Partial<Pick<DotCMSContentTypeField, 'variable'|'required'|'fixed'|'defaultValue'>>. Caveat before wiring it: the @dotcms/dotcms-models barrel pulls Angular in transitively (lib/dot-action-menu-item.model.ts), which you do not want in the MCP server, so deep-import that one model file or skip this half and take the ContentTypeSummary win, which needs nothing new.
Why this matters beyond tidiness: these are response shapes for endpoints the repo already types. A hand-written local copy cannot drift-check against the server contract, so an upstream type change fails silently here instead of at compile time.
**mcp-server/src/lib/page-verify.ts:155 and page-place-content.ts:302 — duplicated inside the PR, and both restate types @dotcms/typesships.**LayoutRowis byte-identical in both files.isLive is implemented twice (page-create.ts:510, assets-transfer.ts:393): two implementations of one liveness probe, same endpoint and same ?depth=0, with two separately hand-written response shapes. The envelopes RenderResponse (:139) and PageJsonResponse (page-place-content.ts:290) overlap, and their container maps (RenderedContainer:148versusRawContainer:298) differ only by inode. That difference is the divergence already starting: two tools parse the **same** /api/v1/page/jsonresponse through two independently maintained shapes. Canonical types, zero runtime cost:DotCMSPageAsset (sdk/types/src/lib/page/public.ts:41), DotPageAssetLayoutRow :81, DotPageAssetLayoutColumn :134, DotCMSColumnContainer :153, DotCMSPage :517, DotCMSLayout :639. *Recommendation:* do **not** swap in DotCMSPageAsset at full strictness. The local shapes make everything optional deliberately, because the payload is unproven at compile time, and that defensiveness is correct. Keep the optionality but derive it (DeepPartial), at minimum reuse Pick<DotCMSColumnContainer, 'identifier'|'uuid'>for the innermost element, and hoist the oneLayoutRow` into a shared module so the two files cannot diverge.
*.../a11y/services/dot-page-sources.service.ts:174 — MEDIUM. Claims to mirror getFileVersionbut inverts its precedence.** The docblock at:172says *"MirrorsgetFileVersionin@dotcms/utils`." It does not:
// libs/utils/src/lib/shared/contentlet.utils.ts:29
contentlet['assetVersion'] || contentlet['fileAssetVersion'] || null
// here, :176-179
contentlet['fileAssetVersion'] || contentlet['assetVersion'] || contentlet['fileAsset'] || nullThey agree when only one key is set, which is why this passes casually, and disagree when both are set. Then the diff viewer fetches a different version of the file than every other admin surface, so a line the user sees in the editor is missing from the diff for no discoverable reason. That is a bad failure mode in the one panel whose job is to be the trustworthy account of what the agent changed before publish.
Fix: import and use getFileVersion. If the extra fileAsset fallback is genuinely needed, add it to the util so both callers share one fixed precedence. A local helper whose comment says "mirrors X" is the thing most likely to silently stop mirroring X.
**.../a11y/store/a11y-page-list.store.ts:112 — LOW-MEDIUM. The _searchenvelope is redeclared inline.**get<{ jsonObjectView: { contentlets: DotCMSContentlet[] }; resultsSize: number }>restates a shape the repo declares twice:ESContent (dotcms-models/src/lib/dot-es-content.model.ts:3, exported from @dotcms/dotcms-models, and already used exactly this way at edit-ema/.../palette/utils/index.ts:304) and DotContentSearchResponse (data-access/.../dot-content-search.service.ts:33, declared on the very service being injected here). This store already imports DotCMSContentlet, so get({...})needs no new dependency. The inline copy omitscontentTookandqueryTook`, so wanting query timing later means a second partial copy rather than widening one shared type.
**mcp-server/src/lib/page-create.ts:504 and :507 — the ascasts defeat the narrowing this file was rewritten to use.** Correction to an earlier reply on this file: four casts remain at HEAD, not zero.:456and:462are fine, being the insides ofresponseEntityandasRecord, and the docblock at :444-450justifies them well. But:504 (asRecord(contentlets[0]) as PageEntity | undefined) and :507 (entity as PageEntity) assert that identifier, inodeandliveexist with the right types off aRecord<string, unknown>when nothing checked them. That is precisely the "type-check a lie, letundefinedsurface in the caller" failure the:444-450docblock argues against, soextractPageEntityre-opens the holeresponseEntity/asRecord/optionalStringwere added to close. *Fix:* read the fields withoptionalString(entity, 'identifier')and an equivalentoptionalBooleanforlive, returning a real PageEntitybuilt from checked values.:504is the same fix applied tocontentlets[0]. The same unchecked-envelope pattern still lives in page-verify.ts:383 (as RenderResponse), page-place-content.ts:329 (as PageJsonResponse), and assets-transfer.ts:326, :397, :712, :729. :729is the sharpest:candidate as AssetContentlet[]asserts the *element* type after only anArray.isArray` check.
Smaller type nits:
dot-a11y-agent.service.ts:84,86:payload as {report?: FixReport}thenpayload as FixReport, with no runtime check. A'report' in payloadguard makes it honest, and this is the enabler for thea11y-run.store.ts:203crash in §2.dot-page-sources.service.ts:213:(view.theme as ThemeSourceView | undefined)is a no-op cast, sinceview.themeis already non-optional (page-render-sources.models.ts:80). Either the model field should be optional or the cast should go.dot-velocity-playground.utils.ts:182:parsed as VelocityWarning[]off aJSON.parseof theX-Dot-Velocity-Warningsheader, guarded only byArray.isArray; element shape unvalidated.a11y-diff-viewer.component.ts:180plus:159/:162/:166/:171: hand-rolled Monaco interfaces behindwindow as unknown as .... Precedent-consistent withdot-velocity-playground/.../register-velocity.ts:1, butmonaco-editorshipsIStandaloneDiffEditorandITextModeland both can beimport type-d. Nit.StudioPageRow(accessibility-studio.models.ts:84) is a deliberate view model, but three fields are pure renames ofDotCMSContentlet(path←url,type←contentType,hostId←host) that cost a mapper and buy nothing, andhostId/hostNamesitting adjacent invites thehostversushostNamemix-up the source type already disambiguates.
5. Other frontend defects
Outside the four themes, but found along the way and worth fixing.
.../a11y/store/a11y-run.store.ts:665 and :677 — HIGH. publish() and discard() only patchState; no backend call exists anywhere in the feature.
Failure: the user scans, the agent fixes the working version, they click "Apply these changes" (a11y-run.component.ts:725). The UI moves to the published phase and reports success, but the working version is never promoted, so the live page keeps every violation while the user believes it is fixed. discard() is worse than a no-op: it advertises a revert, but the working version still holds the agent's edits, so the next editor to publish that page ships changes the user explicitly discarded.
Fix: wire these to the real publish or version-revert endpoints, or disable the buttons and state that promotion is manual. As written, the feature's primary call to action does not do what it says.
**edit-ema/ui/.../dot-page-scanner-report/dot-page-scanner.service.ts:121 — HIGH. A dev-only hack ships in a lib UVE loads in production.** scanUrl.replace('4200', '8080')runs unconditionally, under a comment block saying it must be reverted before merge.String.prototype.replacewith a string pattern rewrites the first occurrence anywhere, not just a port:/products/4200-seriesbecomes/products/8080-series, ?id=a4200f31-...becomes?id=a8080f31-..., and a real host on port 4200 is silently retargeted. The results look legitimate. *Note:* this is the missed sibling of a hack already fixed in a11y-run.store.ts, which now parses the URL and compares url.port === DEV_SERVER_PORTwith a comment naming thedev4200.example.comfalse positive. The fix landed in one of the two places. *Fix:* passscanUrl` through and let the caller or the proxy config resolve the env-aware URL, as the comment itself suggests.
.../a11y/a11y-run/a11y-run.component.ts:311 — HIGH. "Back to page list" is a no-op on multi-segment paths.** The run route is path: '' (a11y.routes.ts:23), so route.snapshot.urlholds all remaining segments, and Angular's..removes one URL **segment**, not one route level. From/agents/a11y/about-us/indexthe back arrow yields/agents/a11y/about-us, which matches again.data: { reuseRoute: false }is **not** inherited by thesnapshot (itsrouteConfig.path !== '', and the parent route has a component so getInheriteddoes not merge parent data), soshouldReuseRoutereturns true, the component instance is kept, and the screen does not change. *Same defect on cold load:* a run URL opened in a new tab has nohistory.state.row (:227), so the constructor calls toPageList(), stays on **, and renders an empty studio with selected === nullwhere Scan silently no-ops. *Fix:*this.router.navigate(['/agents/a11y']). Also handle the returned promise; this and a11y-page-list.component.ts:81` both discard it.
**apps/dotcms-ui/src/app/app.routes.ts:193 — MEDIUM. agentsis the only lazy portlet route with noMenuGuardService.** Answering the question on the old thread: authentication is **not** bypassed, portlet authorization is. MenuGuardService enforces that the portlet is in the user's layout and granted to their role, and every sibling carries it (pages/edit-page, content-types-angular, forms, templates, containers, categories, apps, personas). *Failure:* any authenticated backend user, including a role never granted Accessibility Studio, types /agents/a11y` and gets the full portlet with scan and fix actions. Removing it from a role's layout hides the menu entry but does not block URL entry.
Caveat: the backend half of this needs a backend reviewer, so treat the guard as necessary but not sufficient on its own.
**mcp-server/src/tools/page_place_content.ts:71 — MEDIUM. languageIdis bounds-checked but never validated against the languages already cached.**int().positive()is the only check (same attools/page_create.ts:37, tools/page_verify.ts:23) even though dotcms.loadContext()already returnslanguages, and dotCMS silently falls back to the default language for an unknown id. *Failure:* page_place_content({path:'/about-us', languageId: 12, slots:[...spanish contentlet ids]})where language 12 does not exist.loadPageSlots (lib/page-place-content.ts:326) reads the **English** page, the merged container map is computed from English, and the POST writes to **English**, replacing its content with Spanish contentlets. The manifest returns languageId: 12andwarnings: [], so the model reports "placed on the Spanish page" while English is what changed. *Fix:* resolve languageIdagainstloadContext().languagesbefore any request and fail with the available list, the waysiteandcontentTypealready are. If a fallback is ever intentional, the manifest must report the language actually written. This is the same family as the hardcoded?? 1default atpage-create.ts:151`, and the two compound.
**mcp-server/src/lib/page-place-content.ts:278 — MEDIUM. Bidirectional substring container matching resolves to the wrong container.** containerMatchesaccepts exact, suffix, and "either string contains the other";findContainer (:401) returns the first hit while iterating the layout's container map. *Failure:* a layout has both .../containers/default/and.../containers/default-banner/. A caller asks for .../default/; if default-banneris iterated first, the containment branch matches it and wins. Inmergemode the tool reads the banner's contentlet list and writes the merged result back under it, so content lands in the wrong container and the banner's own contents can be replaced. Object iteration is insertion order, so which container wins depends on layout authoring order, making it non-deterministic from the caller's side. *Fix:* collect all matches, prefer exact then suffix on a/` boundary, and fail loudly with the candidate list when more than one still matches.
**mcp-server/src/lib/page-verify.ts:346 — MEDIUM. A clean bill of health for a page with zero slots.** collectWarnings (:274) iterates slots, so []yieldswarnings: [], and this line interpolates slots.lengthinto a success sentence. Legacy or advanced templates, and any response whereentity.layout.body.rowsis absent whileentity.containersis populated, giveslots: [] (:180-191). With a non-empty page.rendered: "Page rendered successfully in LIVE mode, all 0 slot(s) produced content."Every slot could be blank and the tool built to catch blank slots declares success. *Mirror image:*resolveSlot (lib/page-place-content.ts:232-237) throws The page has 0 slot(s): (none)for that same page, which reads as "no containers" rather than "the layout could not be parsed". *Fix:* a distinct branch forslots.length === 0`.
**mcp-server/src/lib/page-create.ts:147 — worth a look, with a caveat. extraFieldsis spread into the fire body with no key deny-list.** The comment at:145-146promises the typed fields win on "the keys they own".identifierandinodeare not among them, andassertRequiredFieldsSatisfied (:310) only validates *missing* values, never unexpected keys. On the evidence I could read, MapToContentletPopulator.processIdentifier (dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java:1014-1026) treats an identifier in the map as "load the existing contentlet, copy properties onto the incoming one, blank the inode", which would make the PUBLISH fire save a new live version of that existing content. **That backend half needs a backend reviewer to confirm**, so take it as a question rather than a claim. The TypeScript-side fix stands regardless of the answer: reject reserved keys outright rather than relying on spread order (identifier, inode, contentType, stInode, live, working, deleted, modUser, owner). Note also that destructiveHint: false (tools/page_create.ts:74`) means the MCP client will not prompt the user.
**mcp-server/src/lib/page-place-content.ts:321 — LOW-MEDIUM, latent. Unencoded path interpolation allows ..retargeting.** Here and at:327(alsopage-verify.ts:114-116,381) uriis only leading-slash-normalised then interpolated into ``/api/v1/page/json${uri}``.requestCore policy-checks the **raw** string (sdk/ai/src/adapter/request-core.ts:328) and normalises afterwards (:351), so /../../../../api/v1/users/currentwould pass an/api/v1/page/prefix allowlist and then resolve elsewhere. **Latent, not live:** noallow policy is configured today (lib/runtime.ts:13-22), but it becomes a real bypass the moment one is added, which is the natural next hardening step for this tool surface. Already wrong today with no policy involved: page_verify({path:'/a/../b'})renders/bwhile the manifest reports/a/../b, and a #silently truncates. *Fix:* reusesplitUrlPath (lib/page-create.ts:365-403`), which already does this correctly in this same PR, and report the normalised path.
*.../a11y/store/a11y-page-list.store.ts:44 — MEDIUM. No languageIdclause, so multilingual sites list every page twice.**buildPagesQueryemits+working:true +(urlmap: OR basetype:5) +deleted:false +conhost:. toPageRowcarrieslanguageId but the table renders only title, path, type, status and date (a11y-page-list.component.html:63-110). On demo (en-us plus es) every page appears twice, identically, the "N of M" count doubles, and which row the user clicks is arbitrary, so someone fixing the English page can silently scan and fix the Spanish one. openSelectedPage (a11y-run.store.ts:443-448) dedupes on identifier` only, compounding it.
**.../a11y/store/a11y-page-list.store.ts:55 — MEDIUM. The Lucene escape does not handle whitespace.** q.replace(/[+-&|!(){}[]^"~?:\/]/g, '\$&') (:54) escapes metacharacters but leaves spaces. Searching about usproduces+(title:about OR path:about us OR urlmap:about us): the whitespace terminates the field-qualified term, so us*becomes a bare token against the default field inside theORgroup. The picker returns any content whose default field starts withus(users, USA, usage), and the intendedpath/urlmap` wildcard is never applied to the full phrase.
**.../a11y/models/a11y-groups.ts:59 — LOW-MEDIUM. The axe targetarray is joined into a selector list.**node.target?.join(', ')builds a CSS selector *list* while the comment says "First CSS selector". axe'stargetis an ancestor path for frames and shadow roots, so['iframe#promo','button.cta']makesquerySelectorreturn theiframe` and the overlay outlines the whole embed instead of the button.
Fix: take the last element. Present in the edit-ema copy too, so fix both (see §4).
**libs/ai-ui/.../dot-agent-activity-log/dot-agent-activity-log.component.ts:85 — MEDIUM. Force-scroll fights the user.** The afterRenderEffecttracksworkingText(), which changes every 5 seconds during a run (a11y-run.component.ts:569-581), then sets scroller.scrollTop = scroller.scrollHeight`. The scroller is the nearest scrollable ancestor, which in the studio is the whole scanner pane containing the donut, legend and issue list. Scroll up to read the score ring mid-run and it snaps back about 5 seconds later, for the entire run.
Fix: only auto-scroll when already pinned to the bottom, measured before the write, and do not treat a cosmetic reassurance string as a scroll trigger.
**mcp-server/src/lib/assets-transfer.ts:337 — LOW. The 0-byte fallback uploads a 1-byte file and reports success.** Buffer.from('\n')` is uploaded instead of the empty file, and the manifest reports it uploaded with no warning. For an empty VTL or CSS partial the remote asset differs from the source and the caller cannot see it.
6. My earlier notes, resolved
| Note | Resolution |
|---|---|
assets-transfer.ts:360 and :374, "we should do Promise.all or any other thing, this is not handling errors per request" |
Answered in §3. Promise.all is the wrong tool for both: allSettled for the liveness GETs (#5, #7), sequential plus per-iteration try/catch for the PUBLISH loop (#6). |
assets-transfer.ts:185, "what happens if this fails?" |
The upload loop at :207 does have a per-item try/catch at :208-223, so it is the better-behaved half. The unguarded paths are verifyLive at :227 and the four sites inside it. |
assets-transfer.ts file-level, "double check the error handling and promise handling" |
Covered by §1, §2 and the loop inventory. Also worth knowing: this file has zero tests for downloadAssets/uploadAssets, failures[], or the verifyLive throw path. assets-transfer.spec.ts covers only splitIncludePatterns and includeMatcher, so the file with the weakest error handling has no error-path coverage. |
page-create.ts file-level, "we are awaiting without catching errors" |
The specific damage is ensureFolder at :126, a committed write before the unguarded fire at :139. See §2. |
page-create.ts:151, "the default language is not taken into account" |
Confirmed, and worse than it looks: the default is hardcoded to ?? 1 and a wrong explicit languageId is silently redirected to the default, so the two compound. See tools/page_place_content.ts:71 in §5. |
page-create.ts:206, "review this types, maybe we can reuse something" |
Yes, three of them, and one is already imported into the file. See §4. |
page-create.ts:227, "should we mark this error as [MCP Server - resolve site]: <error>" |
Endorsed, and it should be owned by one helper rather than template strings per site. But formatting is the smaller half: see runtime.ts:26 in §1, where retryable needs to be a field on the result, since MCP hands the model a string and instanceof is unavailable on the far side. |
page-create.ts:89, "check if we don't have anything similar on the repo" |
Not fully answered. No equivalent helper was found for the folder-ensure path, but this deserves a targeted search before closing. Related and confirmed: splitUrlPath (:365-403) already exists in this file and should be reused by page-place-content.ts:321 and page-verify.ts:114-116 instead of string concatenation. |
page-create.ts:375, "why underscore?" |
Still open, author's call. No functional issue found; it reads as a convention not used elsewhere in the file. |
dot-page-scanner.service.ts:121, "we can add this to the proxy" |
Agreed as the direction: the env-aware URL belongs to the caller or the proxy config, not a blind replace in a shared lib. See §5. |
7. Checked and verified clean
Stated so this reads as a review rather than only a defect list.
- Critical Rules: clean on the frontend side. No hardcoded secrets: greps for
api[_-]?key|secret|password|tokenassigned to 16+ character literals across all changed non-lock files returned nothing. Nopom.xmlchanged at all. - Secrets in MCP errors: clean.
AUTH_TOKENis read only atlib/runtime.ts:15,execute.ts:125andsearch.ts:84and passed intocreateRuntime; the token is injected host-side atrequest-core.ts:363and never interpolated into a message. No request body, header or JWT reaches a tool result. - Thrown values: clean. All 34 MCP throw sites throw real
Errorinstances, so no[object Object]reaches the model from that path. The three ad-hoc narrowings (page-place-content.ts:432,page-verify.ts:393,406) andruntime.ts:27all guard withinstanceof. (causeis threaded nowhere, which is the separate finding in §1.) - Angular effect dependencies: clean. All 8
effect()andafterRenderEffectsites in the new code were checked for the read-after-guard pattern where a guard placed before a signal read silently drops that dependency. ALLOWED_PREFIXESallowlist: not a bug.spec-transform.ts:179uses rawstartsWith, which is not segment-aware, but computed against the committed spec (572 paths) it over-admits 0 paths. Latent robustness only, so I dropped it.resolveRefbasename$refresolution: not triggerable.nameOfresolves any$refagainstcomponents.schemasby basename, which would misresolve a#/components/parameters/X, but the spec uses only#/components/schemas/(1527 refs) and no schema name collides with a non-schema component.matchesPattern(spec-transform.ts:84-95): correct. The**→.*then(?<!\.)\*→[^/]+ordering is deliberate and the negative lookbehind correctly protects the.*.- Worker
logscross-execution leakage: none.executor.ts:66-68creates a sandbox perexecuteand disposes it infinally, so the module-levellogsarray cannot survive between runs. - Pre-existing, not introduced here: in
worker-harness.tsonlyresolveRefis new. Thepicklast-segment key collision,table's first-item-only keys, and the unguardedadapterMethodsdestructure in theinithandler all predate this PR, so they should not be attributed to it. PageScannerA11yResponsereshape: no stale consumers left.
8. Overlap with @oidacra's review
Where we independently reached the same conclusion, treat it as two reviewers agreeing rather than one finding restated: a11y-run.store.ts:665 (publish and discard local only), a11y-run.store.ts:647 (stopAgent swallows failures), dot-page-scanner.service.ts:121 (the 4200 hack), app.routes.ts:189/193 (the missing guard), and a11y-groups.ts:59 (target.join).
Two of @oidacra's findings are not duplicated above and should be worked from their comments: a11y-run.store.ts:619 (the error case not bumping previewRevision the way done and aborted do) and a11y-run.store.ts:105 (the initialState and teardown interaction).
Posted by Claude Code on behalf of @zJaaal. Consolidated from 45 inline threads plus 11 shorter notes, removed to keep the PR readable; the analysis is Claude Code's, the account is the one holding the token.
|
Fixing:
Will be in the next commit. |
📌 Guide for Java reviewersThis PR is big (163 files), but only 34 are Java/backend — everything else is frontend, SDK, and MCP server. This comment is the map for the backend half so you don't have to read the whole diff. Why any of this exists: we drove dotCMS's own REST API with AI agents for several build sessions. Every change below traces to a specific, reproducible failure — an undocumented default, a silent zero-result, or a 500 where a 400 belonged. Nothing here is speculative polish. What changed
🔍 Where I need your eyes1. This is the Jackson chokepoint every FQCN input short-circuits before the new code, so normal internal traffic is untouched — please confirm that reading. The concern is the new path:
2. It calls
Also: Last: there's a package-private test constructor (Credit where due: the SSE relay uses 3. VTL
4. One edit changes the 400 message on every write endpoint. Two asks:
5.
✅ Safe to skim
Happy to split any of the above into its own PR if that makes review saner — say the word. |
Working on it
|
|
Quick note on the blocking items from @zJaaal's consolidated review and @claude's review — fixed in 535e2f6f2e.
Also removed Test note: several existing mid-run assertions used Still open, not in this commit:
|
|
Second batch — ba8f66abbd. Ten findings from @zJaaal's consolidated review and @oidacra's threads; the six with inline threads are answered there, the rest below.
Left Test notes, since two of these changed how the suites work:
188 tests in Still open: |
|
Third batch — 09ee788fb7. Frontend UI findings; each thread is answered inline. Two things worth raising here rather than in a thread.
|
| @JsonDeserialize(builder = A11yAgentFixForm.Builder.class) | ||
| public class A11yAgentFixForm { | ||
|
|
||
| private final String identifier; |
There was a problem hiding this comment.
I think there are Swagger annotations we can put here. You can refer to com.dotcms.rest.api.v1.system.permission.SaveUserPermissionsForm for more details.
| * its own page resolution. | ||
| */ | ||
| @JsonDeserialize(builder = A11yAgentFixForm.Builder.class) | ||
| public class A11yAgentFixForm { |
There was a problem hiding this comment.
I think we've been using immutables for these kinds of Form classes. You can refer to com.dotcms.rest.api.v1.drive.AbstractDriveRequestForm for more info.
|
|
||
| // A drawn template's body is parsed by jsoup; a null body NPEs downstream. | ||
| if (template.getBody() == null) { | ||
| throw new BadRequestException("body required when drawed"); |
There was a problem hiding this comment.
Include the Template's title and ID in here so that Support can figure out what Template is failing more easily.
| * <p>All callbacks preserve default behavior (they never substitute a value), so attaching this | ||
| * handler cannot change what the script produces — it only observes.</p> | ||
| */ | ||
| public class CollectingInvalidReferenceHandler implements InvalidReferenceEventHandler { |
|
Final batch — cf139e1313. The 🟠 list from @zJaaal's consolidated review is now complete (28/28). Path interpolation —
|

This PR delivers the AI agent platform for dotCMS and its first agent — the Accessibility Studio. Tracks #36640.
What this is
A new admin surface (
agentsroute) where AI agents assist authors, plus the shared plumbing agents stand on. Agent #1 is the Accessibility Studio: pick a page, run a real axe-core scan, let the agent fix the violations it can (streaming its work live), review the working-vs-live diff, then publish or discard the batch.The design is deliberately agent-agnostic — the streaming-log UI, the SSE transport, and the wire contract are reusable, so agent #2 is a registry entry plus a presenter, not a rebuild.
High-level flow
The fix run makes two passes — a deterministic pass (logs one row per violating element) and an agentic pass (fixes carry no rows). The report accounting reflects both: counts derive from the before/after rescan, not from row counts.
The agentic loop is not in this repo. It runs in a separate service (microservice-accessability#12) on the
@dotcms/aiSDK published fromlibs/sdk/ai. What this PR adds on the loop's behalf is theA11yAgentResourceproxy the Studio streams from — see@dotcms/aiSDK for how the two consumers differ.Changes
Frontend —
dot-agentsportlet (new)agentsroute inapp.routes.ts); a11y is the first registered agent.a11y-page-list.store,a11y-run.store) so each screen owns independent, per-navigation state. See Agents portlet below for the listing/detail pattern and how agents register.Frontend —
@dotcms/ai-ui(new shared kernel)AgentMessageview-model +AgentMessagePresenter<T>seam, and thedot-agent-message/dot-agent-thinking/dot-agent-activity-logcomponents. The a11yA11yAgentPresenteris the first consumer.@dotcms/dotcms-models(AgentRunStep,AgentStreamEvent<T>) and a generic SSE transport in@dotcms/data-access.flowchart TD portlet["dot-agents portlet<br/>shell + agent registry"] a11y["a11y Studio<br/>page list · run · diff"] stores["route-scoped stores<br/>a11y-page-list · a11y-run"] presenter["A11yAgentPresenter"] aiui["@dotcms/ai-ui<br/>message · thinking · activity-log"] da["@dotcms/data-access<br/>DotAgentRunService (SSE)"] models["@dotcms/dotcms-models<br/>AgentStreamEvent · AgentRunStep"] portlet --> a11y --> stores a11y --> presenter --> aiui stores --> da aiui --> models da --> models stores -. "POST /agents/a11y" .-> backend["A11yAgentResource"]Backend —
A11yAgentResource(new)/api/v1/agents/a11y: authenticates the backend user, mints a short-lived JWT, resolves the page identifier to a full agent payload, and relays to the external agent service — JSON/fix, SSE/fix/stream,/stop,/active-run. Agent base URL + service token come from thedotPageScanner-configApp secret, never from request input.Backend — making our own API usable by agents
Why this is in an agent PR: building the MCP server and the a11y agent meant being the first heavy non-human consumer of our own write endpoints, and that surfaced a pattern. A human hitting a 500 opens the logs, asks someone, and retries. An agent can't — it only sees the response, so an opaque error is a dead end it cannot recover from, and a missing description is a parameter it will never pass. Every fix below is one of those dead ends, found by watching an agent fail on it:
@Provider,UnrecognizedPropertyExceptionMapper, turns Jackson's class-name-and-JSON-pointer dump intoUnrecognized field 'notARealField'. Valid fields are: [body, drawed, theme, title]— the agent reads the valid list and retries correctly instead of guessing. Full detail still goes to the server log.TemplateResource.fillTemplatenull-body / unset-themeFolderguards; a malformed request now says so instead of looking like our bug./api/vtl/dynamicreturns{errors:[{message, errorType, templateName, line, column}]}on parse/eval failures, and non-fatal warnings (undefined references — Velocity is non-strict, so these silently render empty) ride along inX-Dot-Velocity-Warnings, length-capped so a big warning set can't blow Tomcat's header limit and turn a 200 into a failure. This is what lets an agent fix its own VTL instead of shipping a blank page.host_id@QueryParamonPageResourceand typed@Schemarequest-body descriptions across the write forms — an agent reads the spec to decide what to send, so an undocumented parameter effectively does not exist.openapi.yamlregenerated to match.These are small, but they're the difference between an agent that self-corrects and one that stalls. They also make the endpoints friendlier for humans and integrators, which is why they're worth keeping regardless of the agent work.
Agents portlet (
libs/portlets/dot-agents)Every agent is the same shape: a listing of things to work on, and a detail screen that works on one of them. The portlet provides that shape once, so an agent is a registry entry plus its own two screens.
Two levels of listing → detail. The pattern repeats, which is what keeps agent #2 cheap:
agents)agents/{id})agents/a11y)agents/a11y/{path})How agents are registered
agent-registry.tsis the single extension point. Everything else — gallery cards and child routes — derives from oneDOT_AGENTSarray, so adding an agent touches no shell, landing, or routing code:lib.routes.tsreads that array and derives the routes:statusis the whole roadmap mechanism:coming-soonagents have noloadChildren, so they render a disabled card and register no route — no dead links, and a planned agent is announceable in one entry.status: 'available'plus a loader is what turns it on.To add agent #2: build its UI under
src/lib/agents/{id}/with its own{id}.routes.ts(listing at'', detail below it), then add oneDOT_AGENTSentry. The gallery card and theagents/{id}route appear automatically.flowchart TD registry["agent-registry.ts<br/>DOT_AGENTS (single source)"] landing["gallery landing<br/>one card per entry"] routes["lib.routes.ts<br/>agents/{id} per entry"] shell["agents shell<br/>router-outlet"] a11y["a11y root<br/>router-outlet"] list["page list<br/>A11yPageListStore"] run["run screen<br/>A11yRunStore"] registry --> landing registry --> routes routes --> shell shell --> a11y a11y -- "''" --> list a11y -- "'**'" --> run list -- "row via router state" --> runWhy the stores are route-scoped
Each level's hosts are thin
<router-outlet>wrappers holding no state (DotAgentsShellComponent,DotA11yRootComponent); state lives in a SignalStore provided at the screen component:A11yPageListStore— provided at the page list. Owns search, pagination, the page query.A11yRunStore— provided at the run screen. Owns one page's scan/fix/review/publish lifecycle.Because the store is provided at the component and not the root, navigating to a different page destroys and recreates it: fresh run state per page, no manual reset, and list state can never leak into a run. The run store also has an
onDestroythat tears down in-flight SSE/scan subscriptions, so navigating away mid-run aborts the streams rather than leaking them.The page list hands the selected row to the run screen in the navigation's
state, so the detail screen needs no lookup of its own. The URL still carries the page's readable path (agents/a11y/blog/post/hello) for display — a wildcard route, since a page path is multi-segment and cannot be a single Angular param. Trade-off worth knowing: the run route is reachable only through the list, so a refresh or pasted run URL bounces back to the listing.What is agent-specific vs shared
The
agents/a11y/folder is the only a11y-aware code. Everything an agent needs to stream its work is shared and agent-agnostic:@dotcms/ai-ui— the activity-log components, driven by anAgentMessageview-modelAgentMessagePresenter<T>— the seam each agent implements (A11yAgentPresentermaps a11y phases/results to log rows); this is the one file agent Test Branch and Commit #2 writes to get a live logDotAgentRunService(@dotcms/data-access) — the generic SSE transport, provided per run route rather than at the root@dotcms/aiSDK (libs/sdk/ai)The execution layer under everything above. Most CMSs hand an AI a fixed menu of tools — it can only do what the vendor pre-built.
@dotcms/aiinverts that: the model writes code, and the runtime runs it in a sandbox against the whole dotCMS API, with auth and policy owned in one place. The ceiling isn't a tool list — it's the API itself. There's no LLM inside; it's the layer beneath a model/agent framework, and it's what dotCMS's own MCP server and first-party agents run on (we ship on it, not just publish it).Two consumers, two verbs. The runtime is deliberately not sandbox-only.
createRuntimeexposes both, and the rule is about authorship:requestis the default — use it when you wrote the call;runis for code you did not write (a model did) and therefore must confine. Both flow through one shared request core, so the allow-list and auth injection apply identically and the two verbs cannot drift.apps/mcp-server, this PR)runrequestThe agentic loop lives in a separate service, not in this repo — the
A11yAgentResourceproxy added here is what the Studio talks to, and that service is what runs the scan → fix → re-scan loop. It consumes@dotcms/aias a published npm package (1.5.6-beta.1), which is why the SDK's public surface and its versioning matter beyond this repo:src/agents/dotcms/client.tswrapscreateRuntime(...).requestas its only transport, then hands the model a curated tool set (locateSources,readAsset,grepAssets,editAsset,solveContrast,rescan) — never a generic request tool, never the bearer token. Same principle as the sandbox path, enforced at the tool boundary instead of by confinement.Governed by construction — safety is the shape of the runtime, not a setting:
fetch/require/process.envare removed.defineAdapteroperations) bounds what any code can reach. Exposescanandread; never exposedelete.Public surface — four subpaths:
@dotcms/ai/runtime(the front door — one runtime, two verbs),@dotcms/ai/sandbox(the generic execution engine, a lint-enforced zero-dotCMS boundary),@dotcms/ai/adapter(the dotCMS wiring — auth, context, operations), and@dotcms/ai/spec(the filtered OpenAPI spec agents query).flowchart LR code["model-written code"] subgraph runtime["@dotcms/ai/runtime (front door)"] sandbox["sandbox<br/>confined worker · no fetch/require/env<br/>(zero-dotCMS boundary)"] adapter["adapter<br/>allow-listed operations"] end token["auth token<br/>(host-side only)"] api["dotCMS API"] code --> sandbox sandbox -- "only door out" --> adapter adapter --> api token -. "injected host-side,<br/>never enters sandbox" .-> adapter spec["@dotcms/ai/spec<br/>filtered OpenAPI"] --> codeWhat this PR changes:
format-result(structured, size-capped result formatting reused by the MCPexecutetool) andworker-harnesshelpers;createSandboxgives eachrun()its ownAbortControllerso a timeout aborts in-flight host work./api/v2/assets,/dA) return a{ __dotcmsBinary, contentType, base64, byteLength }envelope capped at 25 MB (checked againstContent-Lengthbefore buffering); user-supplied fetch URLs are SSRF-guarded (loopback / link-local169.254.0.0/16incl. cloud metadata / RFC-1918 / IPv6 unique-local rejected).spec-transform(extracted from the monolithicgenerate-specscript) —$ref-based output keyed by schema name, with request/response schemas kept and context caps applied, so agentsresolveRef(name, depth)instead of hand-walking refs.MCP server (
apps/mcp-server)The MCP server exposes dotCMS to AI agents as tools, running on the
@dotcms/aiSDK above. This PR turns page authoring from a single fragile call into a three-step, verify-as-you-go workflow, and hardens the general-purpose tools around it.New page-authoring tools — the intended flow is
page_create→page_place_content→page_verify:page_create— creates and publishes a blank page in one safe call. SplitsurlPathinto parent folder + leaf (creating the folder idempotently) to avoid the silent/indexURL-collapse trap; resolves anyHTMLPAGEbase-type content type instead of hard-codinghtmlpageasset; validates user-added required fields before firing; resolves the site to its identifier and sends it ascontentHost(fixes the root-pagehost is nullNPE).page_place_content— populates a page's slots with contentlets after it exists. This is the step that turns a blankpage_createresult into a real page.page_verify— confirms a page actually renders: catches a blank slot, a swallowed VTL error, a cache-stale page, or an unpublished edit — the failure modes that a create-and-publish call reports as success but a human would see as broken.flowchart LR agent["AI agent"] create["page_create<br/>blank page + folder"] place["page_place_content<br/>fill the slots"] verify["page_verify<br/>does it render?"] sdk["@dotcms/ai runtime + adapter"] agent --> create --> place --> verify verify -. "empty slot / VTL error /<br/>cache-stale → fix" .-> place create --> sdk place --> sdk verify --> sdk sdk --> api["dotCMS API"]Supporting tool + SDK hardening:
assets-transferreworked (binary-safe transfer path, expanded coverage).execute(JS sandbox, not VTL): timeout default raised to 45s, results routed through the SDK'sformatSandboxResult.searchreframed as a curated OpenAPI-spec query — aspecglobal with aresolveRef(name, depth)helper and a ~25k-char output cap — instead of hand-walking$refs.upload_assetsaccepts string booleans without thez.coerce "false" → truetrap and uploads empty files as-is.(The binary-envelope, SSRF guard, and spec query these tools rely on come from the
@dotcms/aiSDK — see above.)Reviewer notes
mainis large only becausemainwas merged in (unrelated trunk work). The coherent unit is the agent platform + Studio + authoring/OpenAPI hardening described above.UnrecognizedPropertyExceptionMapperis a new@Providerand JAX-RS auto-discovers it, so it changes the 400 body on every Jackson-deserialized write endpoint in the product, not just the ones in this PR. Status stays 400 and full detail still goes to the server log, but anything asserting on the old raw Jackson message will see different text. Also theTemplateResource.fillTemplateguards.A11yAgentResourceuses an App-secret-derived URL/token and a minted JWT (never the caller's credentials, never request-supplied); the@dotcms/aiadapter rejects loopback/link-local/RFC-1918 URLs (SSRF) and caps binary bodies at 25 MB.🤖 Generated with Claude Code