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
48 changes: 28 additions & 20 deletions ymir/agents/backport_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import os
import re
import shutil
import sys
import traceback
from dataclasses import dataclass
Expand Down Expand Up @@ -963,7 +964,10 @@ async def prepare_normal_backport(state):
package=state.package,
dist_git_branch=state.dist_git_branch,
)
state.unpacked_sources = tasks.get_unpacked_sources(state.local_clone, state.package)
builddir = local_tool_options.get("builddir")
state.unpacked_sources = tasks.get_unpacked_sources(
state.local_clone, state.package, builddir=Path(builddir) if builddir else None
)
for idx, upstream_patch in enumerate(state.upstream_patches):
patch_name = f"{state.jira_issue}-{idx}.patch"
content = await run_tool(
Expand Down Expand Up @@ -1901,25 +1905,29 @@ async def comment_in_jira(state):
workflow.add_step("submit_consolidation_job", submit_consolidation_job)
workflow.add_step("comment_in_jira", comment_in_jira)

response = await workflow.run(
BackportState(
package=package,
dist_git_branch=dist_git_branch,
dist_git_namespace=dist_git_namespace,
upstream_patches=upstream_patches,
jira_issue=jira_issue,
workspace_id=workspace_id,
cve_id=cve_id,
justification=justification,
triage_summary=triage_summary,
fix_version=fix_version,
attempts_remaining=max_build_attempts,
shipped_zstream_candidates=shipped_zstream_candidates or [],
inherited_publication_checkpoint=inherited_publication_checkpoint,
inheritance_disabled=inheritance_disabled,
),
)
return response.state
try:
response = await workflow.run(
BackportState(
package=package,
dist_git_branch=dist_git_branch,
dist_git_namespace=dist_git_namespace,
upstream_patches=upstream_patches,
jira_issue=jira_issue,
workspace_id=workspace_id,
cve_id=cve_id,
justification=justification,
triage_summary=triage_summary,
fix_version=fix_version,
attempts_remaining=max_build_attempts,
shipped_zstream_candidates=shipped_zstream_candidates or [],
inherited_publication_checkpoint=inherited_publication_checkpoint,
inheritance_disabled=inheritance_disabled,
),
)
return response.state
finally:
if builddir := local_tool_options.get("builddir"):
shutil.rmtree(builddir, ignore_errors=True)


def _parse_upstream_patches(upstream_patches_raw: str) -> list[str]:
Expand Down
12 changes: 5 additions & 7 deletions ymir/agents/cve_applicability_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def build_applicability_prompt(
dep_issue_key: str | None,
patch_files: list[str],
unpacked_sources: Path,
local_clone: Path,
prep_ok: bool = True,
) -> str:
cve_label = cve_id or "the CVE"
Expand All @@ -88,7 +87,6 @@ def build_applicability_prompt(
f"cannot verify the full dependency chain — classify as 'Inconclusive'.\n"
)

sources_rel = unpacked_sources.relative_to(local_clone)
if patch_files:
patch_info = "Upstream fix patches are available at: " + ", ".join(patch_files)
else:
Expand All @@ -112,10 +110,10 @@ def build_applicability_prompt(
{rebuild_context}
{patch_info}
{fallback_warning}
The unpacked package source is at: {sources_rel}
The unpacked package source is at: {unpacked_sources}

CRITICAL: Your analysis MUST be based on the package source at
{sources_rel} — this is the actual version shipped in RHEL.
{unpacked_sources} — this is the actual version shipped in RHEL.
Do NOT clone or check the latest upstream repository — it may
already contain the fix, which is irrelevant to whether the
shipped RHEL version is affected. If the fix patch applies
Expand All @@ -127,7 +125,7 @@ def build_applicability_prompt(
(package.json, requirements.txt, go.mod, pom.xml, etc.) does
NOT mean the component is shipped. What matters is whether the
component's actual source or compiled files exist on disk in
{sources_rel}. If the vulnerable library's files are absent
{unpacked_sources}. If the vulnerable library's files are absent
(e.g. no node_modules/<lib>/, no vendored source, `find`
returns empty), classify as "Component not Present" regardless
of what manifests or import statements declare. Manifests can
Expand Down Expand Up @@ -156,12 +154,12 @@ def build_applicability_prompt(
2. If upstream fix patches are available, read them to identify
the specific files and functions modified by the fix.
Then verify whether those files physically exist in
{sources_rel} (use `find` to locate them). If the
{unpacked_sources} (use `find` to locate them). If the
vulnerable library's files are completely absent from
the source tree, that is definitive evidence of Component
not Present — stop and classify accordingly.
3. Search for those files/functions in the package source at
{sources_rel}. Do NOT look at any other copy of the source.
{unpacked_sources}. Do NOT look at any other copy of the source.
If the files do not exist on disk, that is decisive — do not
override this with manifest declarations or import statements.
4. If the vulnerable code is not present, determine why — older
Expand Down
9 changes: 7 additions & 2 deletions ymir/agents/merge_request_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import re
import shutil
import sys
import traceback
from pathlib import Path
Expand Down Expand Up @@ -325,8 +326,12 @@ async def comment_in_mr(state):
workflow.add_step("commit_and_push", commit_and_push)
workflow.add_step("comment_in_mr", comment_in_mr)

response = await workflow.run(State(merge_request_url=merge_request_url))
return response.state
try:
response = await workflow.run(State(merge_request_url=merge_request_url))
return response.state
finally:
if builddir := local_tool_options.get("builddir"):
shutil.rmtree(builddir, ignore_errors=True)

if merge_request_url := os.getenv("MERGE_REQUEST_URL", None):
logger.info("Running in direct mode with environment variables")
Expand Down
9 changes: 7 additions & 2 deletions ymir/agents/mr_consolidation_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import os
import re
import shutil
import time
import traceback
from datetime import timedelta
Expand Down Expand Up @@ -1671,8 +1672,12 @@ async def handle_failure(state):
# CVE / Jira lists are collected from commit footers after branches
# are fetched — do not seed from branch metadata.

response = await workflow.run(initial_state)
return response.state
try:
response = await workflow.run(initial_state)
return response.state
finally:
if builddir := local_tool_options.get("builddir"):
shutil.rmtree(builddir, ignore_errors=True)


_CONSOLIDATED_MARKER = "## Consolidated Backport MR"
Expand Down
39 changes: 22 additions & 17 deletions ymir/agents/rebase_agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import shutil
import sys
import traceback
from pathlib import Path
Expand Down Expand Up @@ -671,23 +672,27 @@ async def comment_in_jira(state):
workflow.add_step("commit_push_and_open_mr", commit_push_and_open_mr)
workflow.add_step("comment_in_jira", comment_in_jira)

response = await workflow.run(
State(
package=package,
dist_git_branch=dist_git_branch,
dist_git_namespace=dist_git_namespace,
version=version,
jira_issue=jira_issue,
workspace_id=workspace_id,
cve_id=cve_id,
fix_version=fix_version,
justification=justification,
triage_summary=triage_summary,
consolidated_issues=consolidated_issues or [],
consolidation_summary=consolidation_summary,
),
)
return response.state
try:
response = await workflow.run(
State(
package=package,
dist_git_branch=dist_git_branch,
dist_git_namespace=dist_git_namespace,
version=version,
jira_issue=jira_issue,
workspace_id=workspace_id,
cve_id=cve_id,
fix_version=fix_version,
justification=justification,
triage_summary=triage_summary,
consolidated_issues=consolidated_issues or [],
consolidation_summary=consolidation_summary,
),
)
return response.state
finally:
if builddir := local_tool_options.get("builddir"):
shutil.rmtree(builddir, ignore_errors=True)

if (
(package := os.getenv("PACKAGE", None))
Expand Down
1 change: 0 additions & 1 deletion ymir/agents/rebuild_consolidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,6 @@ async def _check_sibling_applicability(
dep_issue_key=dep_issue_key,
patch_files=[],
unpacked_sources=unpacked_sources,
local_clone=local_clone,
)
response = await agent.run(
prompt,
Expand Down
74 changes: 48 additions & 26 deletions ymir/agents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import shutil
import subprocess
import tempfile
import unicodedata
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
Expand Down Expand Up @@ -1298,11 +1299,13 @@ async def resolve_current_canonical_mr_title(
return None


def get_unpacked_sources(local_clone: Path, package: str) -> Path:
def get_unpacked_sources(local_clone: Path, package: str, builddir: Path | None = None) -> Path:
"""
Get a path to the root of extracted archive directory tree (referenced as TLD
in RPM documentation) for a given package.
in RPM documentation) for a given package. When *builddir* is given, looks
there instead of under *local_clone*.
"""
base = builddir or local_clone
with Specfile(local_clone / f"{package}.spec") as spec:
name = spec.expand("%{name}")
version = spec.expand("%{version}")
Expand All @@ -1314,23 +1317,25 @@ def get_unpacked_sources(local_clone: Path, package: str) -> Path:
buildsubdir = buildsubdir.split("/")[0]

# RPM 4.20+ uses a per-build directory named %{NAME}-%{VERSION}-build
per_build_dir = local_clone / f"{name}-{version}-build"
per_build_dir = base / f"{name}-{version}-build"
sources_dir = per_build_dir / buildsubdir
if sources_dir.is_dir():
return sources_dir

# Older RPM versions unpack directly under _builddir
sources_dir = local_clone / buildsubdir
sources_dir = base / buildsubdir
if sources_dir.is_dir():
return sources_dir

raise ValueError(f"Unpacked source directory does not exist: {sources_dir}")


async def _fallback_extract_sources(local_clone: Path, package: str) -> Path:
async def _fallback_extract_sources(local_clone: Path, package: str) -> tuple[Path, str]:
"""
Fallback when centpkg/rhpkg prep fails: extract the primary source
archive using Source0 from the spec file.
Returns (unpacked_sources, extract_dir) where extract_dir is a /tmp
path the caller must clean up.
"""
try:
with Specfile(local_clone / f"{package}.spec") as spec:
Expand All @@ -1343,20 +1348,22 @@ async def _fallback_extract_sources(local_clone: Path, package: str) -> Path:
raise ValueError(f"Could not determine source archive for {package}: {e}") from e
logger.info(f"Using Source0 from spec: {archive.name}")

extract_dir = local_clone / "_extracted"
extract_dir.mkdir(exist_ok=True)
extract_dir = Path(tempfile.mkdtemp(prefix="rpmbuild-fallback-"))

cmd = ["/usr/lib/rpm/rpmuncompress", "-x", str(archive)]
logger.info(f"Extracting {archive.name} to {extract_dir}")

exit_code, _, stderr = await run_subprocess(cmd, cwd=extract_dir)
if exit_code != 0:
raise ValueError(f"Failed to extract {archive.name}: {stderr}")

subdirs = [d for d in extract_dir.iterdir() if d.is_dir()]
try:
exit_code, _, stderr = await run_subprocess(cmd, cwd=extract_dir)
if exit_code != 0:
raise ValueError(f"Failed to extract {archive.name}: {stderr}")
subdirs = [d for d in extract_dir.iterdir() if d.is_dir()]
except BaseException:
shutil.rmtree(extract_dir, ignore_errors=True)
raise
if len(subdirs) == 1:
return subdirs[0]
return extract_dir
return subdirs[0], str(extract_dir)
return extract_dir, str(extract_dir)


async def clone_and_prep_sources(
Expand All @@ -1366,10 +1373,11 @@ async def clone_and_prep_sources(
jira_issue: str,
ref: str | None = None,
dist_git_namespace: str | None = None,
) -> tuple[Path, Path, bool]:
) -> tuple[Path, Path, bool, str | None]:
"""
Clone dist-git repo and run centpkg/rhpkg sources + prep.
Returns (local_clone, unpacked_sources, prep_succeeded).
Returns (local_clone, unpacked_sources, prep_succeeded, builddir).
The caller must clean up *builddir* (a /tmp path) when done.
Read-only: no fork, no push — just for source analysis.

Falls back to manual archive extraction if prep fails (e.g. missing
Expand Down Expand Up @@ -1420,20 +1428,34 @@ async def clone_and_prep_sources(
# Run prep locally rather than via MCP gateway: the agent container is
# RHEL-based so rpmbuild evaluates %prep macros correctly, whereas the
# MCP gateway runs Fedora and would expand them differently.
result = await run_tool(
RunPackagePrepTool(),
dist_git_path=str(local_clone),
package=package,
dist_git_branch=dist_git_branch,
)
prep_tool = RunPackagePrepTool()
try:
result = await run_tool(
prep_tool,
dist_git_path=str(local_clone),
package=package,
dist_git_branch=dist_git_branch,
)
except BaseException:
if builddir := (prep_tool.options or {}).get("builddir"):
shutil.rmtree(builddir, ignore_errors=True)
raise

if "Prep FAILED" not in result:
unpacked = get_unpacked_sources(local_clone, package)
return local_clone, unpacked, True
builddir = prep_tool.options.get("builddir")
try:
unpacked = get_unpacked_sources(
local_clone, package, builddir=Path(builddir) if builddir else None
)
except BaseException:
if builddir:
shutil.rmtree(builddir, ignore_errors=True)
raise
return local_clone, unpacked, True, builddir

logger.warning(f"prep failed for {package}, falling back to manual extraction: {result}")
unpacked = await _fallback_extract_sources(local_clone, package)
return local_clone, unpacked, False
unpacked, fallback_builddir = await _fallback_extract_sources(local_clone, package)
return local_clone, unpacked, False, fallback_builddir


class InvalidConsolidationConfigError(Exception):
Expand Down
Loading
Loading