Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,7 @@ _bmad/
.tracemind/
backend/results/
tmp.txt

# Local-only RoboScope presentation deck (bundles commercially-licensed DIN Next
# fonts + viadee-internal brand SVGs — must NOT be published to the public repo)
presentation/
15 changes: 11 additions & 4 deletions _bmad-output/implementation-artifacts/deferred-work.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
## Deferred from: code review of flow-editor-hardening (2026-06-14)
# Deferred Work

- FQN `Library.Keyword` disambiguation for genuine cross-library keyword-name ambiguity (AC-C4 second clause). Precedence project>library>BuiltIn is implemented; the fully-qualified tiebreak is not. Low impact — project/resource keywords already win and true library-vs-library collisions are rare.
- Multi-line `[Tags]` / `[Setup]` / `[Teardown]` `...` continuation lines are dropped by the parser (pre-existing behavior carried over from the original RobotEditor parser). Real round-trip gap for multi-line settings; not introduced by this epic.
- A `[Template]` data cell whose value literally equals a control marker (`IF`/`FOR`/`END`/`VAR`/`RETURN`/…) is classified as a control step rather than a data cell. RF itself is ambiguous here; kept the RF-aligned default and documented it.
Findings surfaced during reviews that are out of scope for the triggering story.

## From gh-35 (fastmcp bump) review — 2026-06-15

- **Offline-bundle build swallows wheel-download errors** (`scripts/build-mac-and-linux.sh` ~L178/L186, `| grep ... || true`, `2>/dev/null || true`). If a transitive dep is sdist-only or lacks a wheel for a target ABI, the wheel is silently omitted from `wheels/` and the failure only surfaces at the customer's offline `install-mac-and-linux.sh` (`uv pip install --no-index`), which aborts under `set -euo pipefail`. Pre-existing design weakness; the fastmcp 3.x bump widened the transitive surface (added cyclopts, griffelib, jsonref, openapi-pydantic, opentelemetry-api, py-key-value-aio, watchfiles, …). All current new deps ship `py3-none-any` wheels (verified in uv.lock), so no break today — but the build should fail loudly on a missing wheel rather than ship an incomplete bundle. Suggested fix: collect download failures and exit non-zero (or assert wheel count) before packaging.

## From the security sweep (Dependabot, all 34 alerts) — 2026-06-15

- **starlette CVE GHSA-86qp-5c8j-p5mr (medium) — BLOCKED by FastAPI.** Host-header validation gap poisons `request.url.path`, bypassing path-based security checks. Fix is starlette `1.0.1`, but the latest FastAPI (0.135.4) still pins starlette `0.x` (resolves 0.52.1), so the fix is not reachable without breaking FastAPI. **Low real exposure for RoboScope:** RBAC is FastAPI-dependency-based (not `request.url.path` string matching), and prod runs behind a Host-validating nginx. Re-attempt once a FastAPI release supports starlette 1.x. (Pinning starlette `>=1.0.1` now would break the FastAPI install.)
- ~~**Extension keeps TWO lockfiles**~~ — **RESOLVED 2026-06-16:** standardized the extension on npm — removed `extension/yarn.lock` and the yarn-only `resolutions` block (kept npm `overrides`). Repo is now npm-only across frontend/extension/e2e (backend uses uv). npm audit = 0 vulns, 64 mocha tests pass; no other `yarn.lock`/`.yarnrc` remains in the repo.
84 changes: 84 additions & 0 deletions _bmad-output/implementation-artifacts/spec-gh-35-bump-fastmcp-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: 'Bump fastmcp to >=3.2.4 (close 3 fastmcp 2.x security advisories) — gh-35'
type: 'chore'
created: '2026-06-15'
status: 'done'
baseline_commit: '65face19e5fa66d8446d4e09c4163923425de43a'
context:
- '{project-root}/_bmad-output/project-context.md'
---

<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">

## Intent

**Problem:** RoboScope pins `fastmcp<3` (locked 2.14.7) as a transitive dep of rf-mcp. fastmcp 2.x carries 3 unpatched security advisories (CVE-2026-32871 SSRF/path-traversal in the OpenAPI provider, CVE-2026-27124 OAuth-proxy confused-deputy, + a command-injection), all fixed in fastmcp 3.2.0+. Issue #35 tracks lifting the cap once rf-mcp supports fastmcp 3.

**Approach:** Raise the pins to `fastmcp>=3.2.4` (skips the 3.0.0–3.2.3 auth-header-leak window) and `rf-mcp>=0.31.2`, regenerate `uv.lock`, and verify the rf-mcp HTTP server still boots and the AI test suite stays green. Already empirically confirmed this session: rf-mcp 0.31.2 imports and serves over HTTP under fastmcp 3.4.2; RoboScope uses rf-mcp only transitively, as an out-of-process HTTP client (zero direct `fastmcp` imports).

## Boundaries & Constraints

**Always:** Keep the floor at `fastmcp>=3.2.4` (never 3.0.0–3.2.3). Pin `rf-mcp>=0.31.2`. Single resolved lock head. Offline-first invariants unchanged.

**Ask First:** If `uv lock` resolves fastmcp to a version where the rf-mcp HTTP smoke boot fails, or if any rf-mcp/AI test regresses in a way that needs a code change beyond the pins — HALT and report before editing application code.

**Never:** Touch application logic in `src/ai/rf_mcp_manager.py` or elsewhere (this is a dependency bump, not a refactor). Add a direct `fastmcp` dependency to RoboScope. Bump unrelated deps. Re-introduce a `<` upper cap on fastmcp.

## I/O & Edge-Case Matrix

| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Lock resolves | `uv lock` after pin bump | `fastmcp` ≥3.2.4 (expect 3.4.x), `rf-mcp` ≥0.31.2, single head | If resolution conflicts, HALT |
| rf-mcp boots | `python -m robotmcp.server --transport http --port P` | "Application startup complete", Uvicorn serves, server stays alive | If exits early, HALT with stderr |
| AI tests | `pytest backend/tests/ai` | All pass (rf_mcp_manager, rf_knowledge, router) | Investigate any failure |

</frozen-after-approval>

## Code Map

- `backend/pyproject.toml` -- dependency pins (`rf-mcp>=0.30.0`, `fastmcp<3`) at lines 46–47
- `backend/uv.lock` -- resolved versions (fastmcp 2.14.7, rf-mcp 0.31.1) — regenerate
- `backend/src/ai/rf_mcp_manager.py` -- launches `python -m robotmcp.server --transport http` (read-only; do not edit)
- `_bmad-output/project-context.md` -- "Do-Not-Upgrade Pins" lists `fastmcp <3` as load-bearing — must be corrected
- `.gitignore` -- already adds `presentation/` (rides along on this branch)

## Tasks & Acceptance

**Execution:**
- [x] `backend/pyproject.toml` -- set `fastmcp>=3.2.4` and `rf-mcp>=0.31.2` -- lift the cap, keep a safe floor
- [x] `backend/uv.lock` -- `uv lock` + `uv sync --extra dev` -- resolved fastmcp **3.2.4**, rf-mcp **0.31.2** (also dropped fastmcp 2.x baggage: redis/prometheus/pydocket/diskcache/fakeredis)
- [x] `_bmad-output/project-context.md` -- replaced the `fastmcp <3` pin note with the `>=3.2.4` floor + reason
- [x] verification -- rf-mcp HTTP server boots clean under fastmcp 3.2.4; `pytest tests/ai` = **187 passed** (exit 0); no `.py` source changed → no lint/type regression

**Acceptance Criteria:**
- Given the bumped pins, when `uv lock` runs, then fastmcp resolves to ≥3.2.4 with a single lock head and rf-mcp ≥0.31.2.
- Given the synced `.venv`, when `python -m robotmcp.server --transport http --port <free>` runs, then it reaches "Application startup complete" and stays alive ≥3s.
- Given the bump, when `pytest backend/tests/ai` runs, then all AI tests pass with no new failures.
- Given the change set, when the PR is opened, then it links/closes issue #35.

## Verification

**Commands:**
- `cd backend && uv lock && uv sync` -- expected: clean resolve, fastmcp ≥3.2.4, rf-mcp ≥0.31.2
- `cd backend && grep -A2 'name = "fastmcp"' uv.lock | head` -- expected: version 3.x
- `cd backend && .venv/bin/python -m robotmcp.server --transport http --port 9098` (bg, then kill) -- expected: "Application startup complete"
- `cd backend && .venv/bin/pytest tests/ai -q` -- expected: all pass
- `cd backend && .venv/bin/ruff check src/ tests/` -- expected: clean (no code changed, sanity only)

## Spec Change Log

- **2026-06-15 (review iter 1):** Blind-hunter finding — `fastmcp>=3.2.4` had no upper bound; CI/Docker/offline builds resolve fresh from `pyproject.toml` (not `uv.lock`, confirmed by edge-hunter), so a future fastmcp 4.0 could be pulled in untested and break rf-mcp. **Amended** the pin to `fastmcp>=3.2.4,<4` (patch). This intentionally relaxes the frozen "Never: re-introduce a `<` upper cap" — that wording was too absolute; the genuine intent was "never re-pin below 3.x (re-opening the CVEs)", which `<4` honours while adding a sane major guard. **KEEP:** floor stays `>=3.2.4`; never lower to `<3`. Offline-build error-swallowing finding → deferred-work.md.

## Suggested Review Order

- The whole change in one place: the dependency pins (floor + reason).
[`pyproject.toml:46`](../../backend/pyproject.toml#L46)

- Resolved versions — confirm fastmcp `3.2.4`, rf-mcp `0.31.2`, single head, and the dropped fastmcp 2.x baggage.
[`uv.lock`](../../backend/uv.lock)

- The agent-facing rule that prevents a future re-pin to `<3`.
[`project-context.md:22`](../project-context.md#L22)

- Incidental housekeeping — keep the local-only presentation deck out of the public repo.
[`.gitignore:72`](../../.gitignore#L72)
2 changes: 1 addition & 1 deletion _bmad-output/project-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ _Critical rules and patterns AI agents must follow when implementing code in thi

### Do-Not-Upgrade Pins (each one is load-bearing)

- **`fastmcp <3`** — hard cap from `rf-mcp` compatibility. Bumping breaks the AI module's MCP client.
- **`fastmcp >=3.2.4,<4`** — security floor + major guard. Pulled in transitively via `rf-mcp` (≥0.31.2, which runs on fastmcp 3.x). `>=3.2.4` closes 3 fastmcp 2.x CVEs and skips the 3.0.0–3.2.3 auth-header-leak window (issue #35); `<4` keeps an untested fastmcp 4.0 out of fresh-resolution builds (CI/Docker/offline bundle install from `pyproject.toml`, not `uv.lock`) while still allowing every 3.x security update. RoboScope never imports `fastmcp` directly — it talks to `rf-mcp` as an out-of-process HTTP server (`src/ai/rf_mcp_manager.py`), so the protocol boundary, not the fastmcp API, is what matters. Do NOT re-pin to `<3` (re-opens the CVEs).
- **`vue-i18n ^10`** — v11 rewrote the message compiler. Reserved chars (`@ | { }`) must be escaped (`admin{'@'}roboscope.local`). Dev build is lenient; **prod bundle fails silently** — component renders blank. Always test prod build for i18n changes.
- **`pinia ^2`, `vue-router ^4`, `vue ^3.5`** — not upgraded to pinia 3 / vue-router 5 / vue 3.6 until explicit decision; treat as pinned.
- **`typescript ~5.5`** — locked on minor; `vue-tsc` + Vite plugin compat. Do not bump to 5.6+.
Expand Down
17 changes: 12 additions & 5 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ dependencies = [
"pydantic-settings>=2.6.0",
"PyJWT>=2.9.0",
"bcrypt>=4.2.0",
"python-multipart>=0.0.12",
"python-multipart>=0.0.27",
"httpx>=0.28.0",
"gitpython>=3.1.43",
"gitpython>=3.1.50",
"robotframework>=7.1",
"websockets>=14.1",
"apscheduler>=3.10.4",
Expand All @@ -43,10 +43,17 @@ dependencies = [
"httpx>=0.28.0",
"slowapi>=0.1.9",
"python-json-logger>=3.0.0",
"rf-mcp>=0.30.0",
"fastmcp<3",
"rf-mcp>=0.31.2",
# fastmcp pulled in transitively via rf-mcp. Floor >=3.2.4 closes the
# fastmcp 2.x CVEs (CVE-2026-32871 / CVE-2026-27124 / cmd-injection) and
# skips the 3.0.0-3.2.3 auth-header-leak window. The <4 ceiling stops an
# untested fastmcp 4.0 from being pulled into fresh-resolution builds
# (CI/Docker/offline bundle resolve from this file, not uv.lock) and
# breaking rf-mcp; all 3.x security updates still flow. RoboScope serves
# rf-mcp out-of-process over HTTP and never imports fastmcp. See issue #35.
"fastmcp>=3.2.4,<4",
"playwright>=1.49.0",
"authlib>=1.6.10",
"authlib>=1.6.12",
"reportlab>=4.4.10",
# XXE / billion-laughs hardening for any XML coming from
# subprocess output (output.xml is built from user-authored .robot
Expand Down
119 changes: 119 additions & 0 deletions backend/src/ai/patch_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Apply a unified-diff patch to a file's text.

The LLM failure-analysis returns fix suggestions as fenced ``patch`` blocks
(see ``prompts.py`` / ``patch_extractor.py``). To let the user accept a fix
with one click we need to APPLY that diff to the on-disk ``.robot`` file.

No third-party patch library is available (RoboScope is offline-only and ships
a minimal dependency set), so this is a small, dependency-free applier. It is
deliberately *context-driven* rather than line-number-driven: an LLM's `@@`
line numbers are frequently off by a few lines, so we locate each hunk by its
context + removed lines and ignore the stated offsets (using them only as a
tie-breaker hint when the same block appears more than once). If a hunk cannot
be located unambiguously the whole apply fails with ``PatchApplyError`` — we
never write a partially- or wrongly-applied file.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")


class PatchApplyError(ValueError):
"""Raised when a unified diff cannot be applied cleanly."""


@dataclass
class _Hunk:
old_start: int # 1-based line in the original the hunk targets (hint only)
before: list[str] # context + removed lines (the text we expect to find)
after: list[str] # context + added lines (what it becomes)


def _parse_hunks(diff: str) -> list[_Hunk]:
lines = diff.splitlines()
hunks: list[_Hunk] = []
i = 0
while i < len(lines):
m = _HUNK_RE.match(lines[i])
if not m:
i += 1
continue
old_start = int(m.group(1))
before: list[str] = []
after: list[str] = []
i += 1
while i < len(lines) and not lines[i].startswith("@@"):
line = lines[i]
# Stop if we hit the start of another file's diff header.
if line.startswith(("--- ", "+++ ", "diff ", "index ")):
break
if line.startswith("\\"): # "\ No newline at end of file"
i += 1
continue
tag, body = (line[:1], line[1:]) if line else (" ", "")
if tag == " ":
before.append(body)
after.append(body)
elif tag == "-":
before.append(body)
elif tag == "+":
after.append(body)
else:
# Unrecognised line inside a hunk → malformed diff.
raise PatchApplyError(f"Unexpected line in hunk: {line!r}")
i += 1
hunks.append(_Hunk(old_start=old_start, before=before, after=after))
if not hunks:
raise PatchApplyError("No hunks found in diff")
return hunks


def _find_block(haystack: list[str], needle: list[str], hint: int) -> int:
"""Return the index where `needle` occurs in `haystack`, choosing the
occurrence closest to `hint` when there are several. -1 if not found."""
if not needle:
# Pure insertion: anchor at the hint line (clamped).
return max(0, min(hint, len(haystack)))
matches: list[int] = []
last = len(haystack) - len(needle)
for start in range(0, last + 1):
if haystack[start : start + len(needle)] == needle:
matches.append(start)
if not matches:
return -1
return min(matches, key=lambda idx: abs(idx - hint))


def apply_unified_diff(original: str, diff: str) -> str:
"""Apply `diff` (a unified diff) to `original`, returning the patched text.

Raises ``PatchApplyError`` if any hunk cannot be located, so callers can
treat a failed apply as "not safe to auto-fix" and fall back to manual.
"""
hunks = _parse_hunks(diff)

# Preserve a trailing newline across the round-trip: split/strip the final
# empty element and re-add it on join.
had_trailing_nl = original.endswith("\n")
lines = original.split("\n")
if had_trailing_nl:
lines.pop() # drop the empty tail from the trailing "\n"

for hunk in hunks:
hint = max(0, hunk.old_start - 1)
idx = _find_block(lines, hunk.before, hint)
if idx < 0:
raise PatchApplyError(
"Could not locate the context for a hunk — the file has "
"changed since the analysis. Apply the patch manually."
)
lines[idx : idx + len(hunk.before)] = hunk.after

result = "\n".join(lines)
if had_trailing_nl:
result += "\n"
return result
31 changes: 31 additions & 0 deletions backend/src/ai/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,37 @@
"""


# Frontend i18n locales → human language names for the prompt directive.
_LANGUAGE_NAMES = {
"de": "German (Deutsch)",
"en": "English",
"fr": "French (Français)",
"es": "Spanish (Español)",
"zh": "Chinese (中文)",
}


def language_directive(locale: str | None) -> str:
"""A system-prompt suffix telling the model to answer in the user's UI
language. Returns "" for an unset/unknown locale (model stays in English).

Code stays in English: only prose is translated, so Robot Framework
keywords, paths, and the unified-diff patches remain valid.
"""
if not locale:
return ""
name = _LANGUAGE_NAMES.get(locale.split("-")[0].lower())
if not name:
return ""
return (
"\n\n## Response Language\n"
f"Write your ENTIRE response — executive summary, analysis, headings, "
f"and prose — in {name}. Do NOT translate code: keep Robot Framework "
"keywords, variable names, file paths, and the unified-diff patch "
"blocks exactly as they are."
)


def build_analyze_user_prompt(
report_summary: dict,
failed_tests: list[dict],
Expand Down
Loading
Loading