diff --git a/.github/scripts/update_debian_snapshot.py b/.github/scripts/update_debian_snapshot.py new file mode 100644 index 0000000000..4f6ce7bd6a --- /dev/null +++ b/.github/scripts/update_debian_snapshot.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Advance the agent-server Debian snapshot after a seven-day observation period.""" + +from __future__ import annotations + +import argparse +import re +import urllib.request +from datetime import UTC, datetime, timedelta +from pathlib import Path + + +MINIMUM_AGE = timedelta(days=7) +SNAPSHOT_RE = re.compile(r"(?m)^ARG DEBIAN_SNAPSHOT=(\d{8}T\d{6}Z)$") +ARCHIVES = ("debian", "debian-security") + + +def eligible_snapshot(now: datetime) -> datetime: + if now.tzinfo is None: + raise ValueError("now must be timezone-aware") + cutoff = now.astimezone(UTC) - MINIMUM_AGE + return cutoff.replace(hour=0, minute=0, second=0, microsecond=0) + + +def format_snapshot(value: datetime) -> str: + return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + + +def validate_snapshot_age(snapshot: datetime, now: datetime) -> None: + age = now.astimezone(UTC) - snapshot.astimezone(UTC) + if age < MINIMUM_AGE: + raise ValueError(f"snapshot is only {age} old; minimum age is {MINIMUM_AGE}") + + +def verify_snapshot(snapshot: str) -> None: + for archive in ARCHIVES: + url = f"https://snapshot.debian.org/archive/{archive}/{snapshot}/" + request = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200: + raise ValueError(f"{url} returned HTTP {response.status}") + except OSError as exc: + raise ValueError(f"unable to verify {url}: {exc}") from exc + + +def current_snapshot(path: Path) -> str: + matches = SNAPSHOT_RE.findall(path.read_text(encoding="utf-8")) + if len(matches) != 1: + raise ValueError(f"expected exactly one DEBIAN_SNAPSHOT in {path}") + return matches[0] + + +def update_dockerfile(path: Path, snapshot: str) -> bool: + current = current_snapshot(path) + if snapshot <= current: + return False + text = path.read_text(encoding="utf-8") + updated = SNAPSHOT_RE.sub(f"ARG DEBIAN_SNAPSHOT={snapshot}", text) + path.write_text(updated, encoding="utf-8") + return True + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dockerfile", type=Path, required=True) + parser.add_argument( + "--now", + type=lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")), + default=datetime.now(UTC), + help="UTC reference time for deterministic testing", + ) + parser.add_argument("--skip-network-check", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + snapshot_time = eligible_snapshot(args.now) + validate_snapshot_age(snapshot_time, args.now) + snapshot = format_snapshot(snapshot_time) + if not args.skip_network_check: + verify_snapshot(snapshot) + changed = update_dockerfile(args.dockerfile, snapshot) + print(f"Debian snapshot: {snapshot} ({'updated' if changed else 'unchanged'})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 215db20840..9742e0a736 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -11,7 +11,7 @@ on: base_image: description: Base runtime image type: string - default: nikolaik/python-nodejs:python3.13-nodejs22-slim + default: python-node-runtime image: description: GHCR image name type: string @@ -285,7 +285,7 @@ jobs: image_flavor: default acp_provider_flavor: default arch: amd64 - base_image: nikolaik/python-nodejs:python3.13-nodejs22-slim + base_image: python-node-runtime runner: ubuntu-24.04 platform: linux/amd64 @@ -294,7 +294,7 @@ jobs: image_flavor: default acp_provider_flavor: default arch: arm64 - base_image: nikolaik/python-nodejs:python3.13-nodejs22-slim + base_image: python-node-runtime runner: ubuntu-24.04-arm platform: linux/arm64 @@ -304,7 +304,7 @@ jobs: image_flavor: slim acp_provider_flavor: none arch: amd64 - base_image: nikolaik/python-nodejs:python3.13-nodejs22-slim + base_image: python-node-runtime runner: ubuntu-24.04 platform: linux/amd64 @@ -313,7 +313,7 @@ jobs: image_flavor: slim acp_provider_flavor: none arch: arm64 - base_image: nikolaik/python-nodejs:python3.13-nodejs22-slim + base_image: python-node-runtime runner: ubuntu-24.04-arm platform: linux/arm64 diff --git a/.github/workflows/typescript-client-integration-tests.yml b/.github/workflows/typescript-client-integration-tests.yml index f78ca870cb..8dd35ba419 100644 --- a/.github/workflows/typescript-client-integration-tests.yml +++ b/.github/workflows/typescript-client-integration-tests.yml @@ -70,7 +70,7 @@ jobs: uv sync --frozen uv run ./openhands-agent-server/openhands/agent_server/docker/build.py \ --build-ctx-only --arch amd64 \ - --base-image nikolaik/python-nodejs:python3.13-nodejs22-slim + --base-image python-node-runtime - name: Build agent-server image from this branch uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 @@ -87,7 +87,7 @@ jobs: # Scope server.yml populates with mode=max on push to main. cache-from: type=gha,scope=agent-server-python-amd64 build-args: | - BASE_IMAGE=nikolaik/python-nodejs:python3.13-nodejs22-slim + BASE_IMAGE=python-node-runtime OPENHANDS_BUILD_GIT_SHA=${{ github.event.pull_request.head.sha || github.sha }} OPENHANDS_BUILD_GIT_REF=${{ github.head_ref != '' && format('refs/heads/{0}', github.head_ref) || github.ref }} INSTALL_ACP_PROVIDERS=claude-code,codex,gemini-cli diff --git a/.github/workflows/update-debian-snapshot.yml b/.github/workflows/update-debian-snapshot.yml new file mode 100644 index 0000000000..eb6ceea02f --- /dev/null +++ b/.github/workflows/update-debian-snapshot.yml @@ -0,0 +1,101 @@ +--- +name: Update Agent Server Debian Snapshot + +on: + schedule: + - cron: 17 6 * * 1 + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-agent-server-debian-snapshot + cancel-in-progress: true + +jobs: + update-snapshot: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + GH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} + BRANCH: chore/update-agent-server-debian-snapshot + DOCKERFILE: openhands-agent-server/openhands/agent_server/docker/Dockerfile + IMAGE: openhands/agent-server-binary-minimal:snapshot-update + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} + + - name: Select the newest snapshot older than seven days + run: python .github/scripts/update_debian_snapshot.py --dockerfile "$DOCKERFILE" + + - name: Build binary-minimal + run: | + docker build \ + --target binary-minimal \ + --build-arg INSTALL_ACP_PROVIDERS= \ + --tag "$IMAGE" \ + --file "$DOCKERFILE" . + + - name: Scan binary-minimal + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.IMAGE }} + format: json + output: trivy.json + scanners: vuln + + - name: Open or refresh update PR + env: + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if git diff --quiet; then + echo "Debian snapshot is already current." + exit 0 + fi + + SNAPSHOT=$(sed -n 's/^ARG DEBIAN_SNAPSHOT=//p' "$DOCKERFILE") + TRIVY_COUNTS=$(jq -r ' + [.Results[]?.Vulnerabilities[]?] + | group_by(.Severity) + | map("\(.[0].Severity): \(length)") + | join(", ") + ' trivy.json) + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin "$BRANCH" || true + git checkout -B "$BRANCH" + git add "$DOCKERFILE" + git commit -m "chore(agent-server): update Debian snapshot to $SNAPSHOT" \ + -m "Use the newest UTC snapshot that has completed the seven-day observation period." \ + -m "Co-authored-by: openhands " + git push --force-with-lease -u origin "$BRANCH" + + BODY=$(cat < /etc/apt/sources.list.d/debian.sources; \ + apt-get update; \ + apt-get upgrade -y; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libbz2-1.0 \ + libdb5.3t64 \ + libffi8 \ + libgcc-s1 \ + libgdbm6t64 \ + liblzma5 \ + libncursesw6 \ + libreadline8t64 \ + libsqlite3-0 \ + libssl3t64 \ + libstdc++6 \ + libtinfo6 \ + libuuid1 \ + libzstd1 \ + netbase \ + tzdata \ + zlib1g; \ + rm -rf /var/lib/apt/lists/*; \ + python3 --version; \ + node --version; \ + npm --version + #################################################################################### # Builder (source mode) # We copy source + build a venv here for local dev and debugging. @@ -168,9 +232,8 @@ RUN set -eux; \ #################################################################################### # Base image (minimal) -# It includes only basic packages and the UV runtime. -# No Docker, no browser, no VSCode Web. -# Suitable for running in headless/evaluation mode. +# It includes only the packages required to run the agent server and its Bash +# and Git APIs. Development tools and optional capabilities live in base-image. #################################################################################### FROM ${BASE_IMAGE} AS base-image-minimal ARG USERNAME UID GID PORT @@ -192,36 +255,22 @@ RUN set -eux; \ if command -v apt-get >/dev/null 2>&1; then \ apt-get -o Acquire::Retries=5 update; \ apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ - bash ca-certificates curl wget sudo apt-utils git jq tmux tar \ - build-essential coreutils util-linux procps findutils grep sed \ - tini apt-transport-https gnupg lsb-release xz-utils; \ + bash ca-certificates git tini tmux; \ rm -rf /var/lib/apt/lists/*; \ elif command -v apk >/dev/null 2>&1; then \ - apk add --no-cache \ - bash ca-certificates curl wget sudo git jq tmux tar build-base \ - coreutils util-linux procps findutils grep sed tini gnupg shadow xz; \ + apk add --no-cache bash ca-certificates git shadow tini tmux; \ elif command -v microdnf >/dev/null 2>&1; then \ - microdnf install -y \ - bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \ - coreutils util-linux procps-ng findutils grep sed shadow-utils \ - gnupg2 xz; \ + microdnf install -y bash ca-certificates git shadow-utils tmux; \ microdnf clean all; \ elif command -v dnf >/dev/null 2>&1; then \ - dnf install -y \ - bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \ - coreutils util-linux procps-ng findutils grep sed shadow-utils \ - gnupg2 xz; \ + dnf install -y bash ca-certificates git shadow-utils tmux; \ dnf clean all; \ elif command -v yum >/dev/null 2>&1; then \ - yum install -y \ - bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \ - coreutils util-linux procps-ng findutils grep sed shadow-utils \ - gnupg2 xz; \ + yum install -y bash ca-certificates git shadow-utils tmux; \ yum clean all; \ elif command -v zypper >/dev/null 2>&1; then \ zypper --non-interactive install --no-recommends \ - bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \ - coreutils util-linux procps findutils grep sed shadow gpg2 xz; \ + bash ca-certificates git shadow tmux; \ zypper clean --all; \ else \ echo "Unsupported base image: no known package manager found" >&2; \ @@ -230,11 +279,6 @@ RUN set -eux; \ grep -Eq "^[^:]*:[^:]*:${GID}:" /etc/group || groupadd -g "${GID}" "${USERNAME}"; \ grep -Eq "^${USERNAME}:" /etc/passwd || \ useradd -m -u "${UID}" -g "${GID}" -s /bin/bash "${USERNAME}"; \ - # Best-effort: add user to a sudo group when one exists (Debian-style - # `sudo` group). On Alpine/RHEL/SUSE there is no `sudo` group by default, - # and the NOPASSWD sudoers line below grants sudo regardless of group. - usermod -aG sudo "${USERNAME}" 2>/dev/null || true; \ - echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \ mkdir -p /workspace/project; \ chown -R "${USERNAME}:${USERNAME}" /workspace @@ -274,10 +318,6 @@ RUN if ! "$ACP_NODE_DIR/bin/node" --version >/dev/null 2>&1; then \ RUN mkdir -p /etc/claude-code && \ echo '{"permissions":{"allow":["Edit","Read","Bash"]}}' > /etc/claude-code/managed-settings.json -# NOTE: we should NOT include UV_PROJECT_ENVIRONMENT here, -# since the agent might use it to perform other work (e.g. tools that use Python) -COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/ - USER ${USERNAME} WORKDIR / # Locale settings required for libtmux to work with PyInstaller builds @@ -299,6 +339,47 @@ ARG INSTALL_CAPABILITIES USER root +# Full images are general-purpose development environments. Keep compilers, +# package/repository helpers, and command-line utilities out of binary-minimal. +RUN set -eux; \ + if command -v apt-get >/dev/null 2>&1; then \ + apt-get -o Acquire::Retries=5 update; \ + apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ + apt-transport-https apt-utils build-essential coreutils curl \ + findutils gnupg grep jq lsb-release procps sed sudo tar util-linux \ + wget xz-utils; \ + rm -rf /var/lib/apt/lists/*; \ + elif command -v apk >/dev/null 2>&1; then \ + apk add --no-cache \ + build-base coreutils curl findutils gnupg grep jq procps sudo tar \ + util-linux wget xz; \ + elif command -v microdnf >/dev/null 2>&1; then \ + microdnf install -y \ + coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \ + sed sudo tar util-linux wget xz; \ + microdnf clean all; \ + elif command -v dnf >/dev/null 2>&1; then \ + dnf install -y \ + coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \ + sed sudo tar util-linux wget xz; \ + dnf clean all; \ + elif command -v yum >/dev/null 2>&1; then \ + yum install -y \ + coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \ + sed sudo tar util-linux wget xz; \ + yum clean all; \ + elif command -v zypper >/dev/null 2>&1; then \ + zypper --non-interactive install --no-recommends \ + coreutils curl findutils gcc gcc-c++ gpg2 grep jq make procps sed \ + sudo tar util-linux wget xz; \ + zypper clean --all; \ + fi; \ + usermod -aG sudo "${USERNAME}" 2>/dev/null || true; \ + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Keep uv as part of the full development environment, but not binary-minimal. +COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/ + # Fail fast on a typo'd capability name before spending any time on the # installs below (each block only checks for ITS OWN keyword's presence, so # an unrecognized one would otherwise be silently ignored rather than erroring). diff --git a/openhands-agent-server/openhands/agent_server/docker/build.py b/openhands-agent-server/openhands/agent_server/docker/build.py index 1a960158c2..0cf496a156 100755 --- a/openhands-agent-server/openhands/agent_server/docker/build.py +++ b/openhands-agent-server/openhands/agent_server/docker/build.py @@ -393,9 +393,7 @@ def _package_version() -> str: class BuildOptions(BaseModel): - # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue - # with Python 3.13. See issue #1886 for details. - base_image: str = Field(default="nikolaik/python-nodejs:python3.12-nodejs22-slim") + base_image: str = Field(default="python-node-runtime") custom_tags: str = Field( default="", description="Comma-separated list of custom tags." ) @@ -1069,9 +1067,7 @@ def main(argv: list[str]) -> int: ) parser.add_argument( "--base-image", - # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue - # with Python 3.13. See issue #1886. - default=_env("BASE_IMAGE", "nikolaik/python-nodejs:python3.12-nodejs22-slim"), + default=_env("BASE_IMAGE", "python-node-runtime"), help="Base image to use (default from $BASE_IMAGE).", ) parser.add_argument( diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 8d3c8071c6..ecdde20a93 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.47.0" +version = "1.48.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index aabb7d6f86..6143c2d47a 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -2889,7 +2889,6 @@ def ask_agent(self, question: str) -> str: "usage_id": ASK_AGENT_LLM_USAGE_ID, "stream": False, }, - deep=True, ) self.llm_registry.add(question_llm) diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 07fe2596f9..5a8a7261d8 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.47.0" +version = "1.48.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index d4c9ebcf15..7d121eb373 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.47.0" +version = "1.48.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index 8028bb4971..591e51dab4 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.47.0" +version = "1.48.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/tests/cross/test_agent_server_build_metadata.py b/tests/cross/test_agent_server_build_metadata.py index dd67f981e1..c04f1b53ee 100644 --- a/tests/cross/test_agent_server_build_metadata.py +++ b/tests/cross/test_agent_server_build_metadata.py @@ -113,6 +113,46 @@ def test_agent_server_binary_copies_openhands_distribution_metadata() -> None: assert f'*copy_metadata("{distribution}")' in spec_text +def test_python_image_uses_canonical_minimal_runtime() -> None: + dockerfile_text = AGENT_SERVER_DOCKERFILE.read_text(encoding="utf-8") + workflow_text = SERVER_WORKFLOW.read_text(encoding="utf-8") + + assert "FROM debian:trixie-slim AS python-node-runtime" in dockerfile_text + assert "FROM python:3.13.15-slim-trixie AS python-runtime" in dockerfile_text + assert "FROM node:24.21.0-trixie-slim AS node-runtime" in dockerfile_text + assert "ARG BASE_IMAGE=python-node-runtime" in dockerfile_text + assert re.search(r"ARG DEBIAN_SNAPSHOT=\d{8}T000000Z", dockerfile_text) + assert ( + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" + in dockerfile_text + ) + assert ( + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" + in dockerfile_text + ) + assert ( + dockerfile_text.count( + "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" + ) + == 2 + ) + assert "Check-Valid-Until: no" in dockerfile_text + assert "apt-get update; \\\n apt-get upgrade -y;" in dockerfile_text + minimal_stage = "FROM ${BASE_IMAGE} AS base-image-minimal" + full_stage = "FROM base-image-minimal AS base-image" + minimal_packages = dockerfile_text.partition(minimal_stage)[2].partition( + full_stage + )[0] + full_packages = dockerfile_text.partition(full_stage)[2] + assert "build-essential" not in minimal_packages + assert "COPY --from=ghcr.io/astral-sh/uv" not in minimal_packages + assert "build-essential" in full_packages + assert "COPY --from=ghcr.io/astral-sh/uv" in full_packages + assert "nikolaik/python-nodejs" not in dockerfile_text + assert "base_image: python-node-runtime" in workflow_text + assert "nikolaik/python-nodejs" not in workflow_text + + def test_agent_server_dockerfile_has_no_hardcoded_acp_packages() -> None: """The acp-providers stage must resolve packages/versions from the dependency-free catalog at build time, not from Dockerfile-baked arms. diff --git a/tests/cross/test_update_debian_snapshot.py b/tests/cross/test_update_debian_snapshot.py new file mode 100644 index 0000000000..299be89f20 --- /dev/null +++ b/tests/cross/test_update_debian_snapshot.py @@ -0,0 +1,73 @@ +from datetime import UTC, datetime, timedelta +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "update_debian_snapshot.py" +SPEC = spec_from_file_location("update_debian_snapshot", SCRIPT_PATH) +assert SPEC and SPEC.loader +snapshot = module_from_spec(SPEC) +SPEC.loader.exec_module(snapshot) + + +def test_eligible_snapshot_is_at_least_seven_days_old() -> None: + now = datetime(2026, 9, 15, 12, 30, tzinfo=UTC) + + selected = snapshot.eligible_snapshot(now) + + assert selected == datetime(2026, 9, 8, tzinfo=UTC) + assert now - selected >= timedelta(days=7) + + +def test_validate_snapshot_age_rejects_fresh_snapshot() -> None: + now = datetime(2026, 9, 15, 12, 30, tzinfo=UTC) + + with pytest.raises(ValueError, match="minimum age"): + snapshot.validate_snapshot_age(now - timedelta(days=6), now) + + +def test_update_dockerfile_replaces_exactly_one_pin(tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM debian:trixie-slim\nARG DEBIAN_SNAPSHOT=20260901T000000Z\n" + ) + + assert snapshot.update_dockerfile(dockerfile, "20260908T000000Z") + assert "ARG DEBIAN_SNAPSHOT=20260908T000000Z" in dockerfile.read_text() + assert not snapshot.update_dockerfile(dockerfile, "20260908T000000Z") + + +def test_update_dockerfile_does_not_downgrade_newer_pin(tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM debian:trixie-slim\nARG DEBIAN_SNAPSHOT=20260913T000000Z\n" + ) + + assert not snapshot.update_dockerfile(dockerfile, "20260908T000000Z") + assert "ARG DEBIAN_SNAPSHOT=20260913T000000Z" in dockerfile.read_text() + + +def test_update_dockerfile_rejects_missing_pin(tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM debian:trixie-slim\n") + + with pytest.raises(ValueError, match="exactly one"): + snapshot.update_dockerfile(dockerfile, "20260908T000000Z") + + +def test_workflow_builds_and_scans_before_opening_pr() -> None: + workflow = ( + REPO_ROOT / ".github" / "workflows" / "update-debian-snapshot.yml" + ).read_text() + + build = workflow.index("- name: Build binary-minimal") + scan = workflow.index("- name: Scan binary-minimal") + pull_request = workflow.index("- name: Open or refresh update PR") + assert build < scan < pull_request + assert ( + "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25" in workflow + ) + assert "cron: 17 6 * * 1" in workflow diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py index 1ea26c1ddd..927172c05d 100644 --- a/tests/examples/test_examples.py +++ b/tests/examples/test_examples.py @@ -59,6 +59,8 @@ "examples/01_standalone_sdk/35_subscription_login.py", # Requires interactive input() which fails in CI with EOFError "examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py", + # Requires a Kubernetes cluster with agent-sandbox and the agent-sandbox extra + "examples/02_remote_agent_server/17_convo_with_agent_sandbox_server.py", } diff --git a/tests/sdk/conversation/test_ask_agent.py b/tests/sdk/conversation/test_ask_agent.py index 9c112eda1f..e69452dcac 100644 --- a/tests/sdk/conversation/test_ask_agent.py +++ b/tests/sdk/conversation/test_ask_agent.py @@ -275,6 +275,32 @@ def test_ask_agent_disables_streaming_when_llm_streams(mock_transport, tmp_path) assert conv.llm_registry.get("ask-agent-llm").stream is False +@patch("openhands.sdk.llm.llm.LLM._transport_call", autospec=True) +def test_ask_agent_during_in_flight_llm_call(mock_transport, tmp_path, agent): + """Regression test for #5082: while the agent LLM has a call in flight its + telemetry holds the open span, which cannot be deep-copied. + """ + conv = Conversation( + agent=agent, + persistence_dir=str(tmp_path), + workspace=str(tmp_path), + ) + answers = [] + + def transport(llm, *args, **kwargs): + if llm.usage_id != "ask-agent-llm": + answers.append(conv.ask_agent("How's the progress?")) + return create_mock_model_response("answer") + + mock_transport.side_effect = transport + agent.llm.completion( + messages=[Message(role="user", content=[TextContent(text="hi")])] + ) + + assert answers == ["answer"] + assert conv.llm_registry.get("ask-agent-llm").telemetry is not agent.llm.telemetry + + @patch("openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient") def test_remote_conversation_ask_agent(mock_ws_client, agent): mock_ws_client.return_value.wait_until_ready.return_value = True diff --git a/uv.lock b/uv.lock index 82d1a84a77..c77f11f1c1 100644 --- a/uv.lock +++ b/uv.lock @@ -2774,7 +2774,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.47.0" +version = "1.48.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2814,7 +2814,7 @@ provides-extras = ["posthog"] [[package]] name = "openhands-sdk" -version = "1.47.0" +version = "1.48.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2876,7 +2876,7 @@ provides-extras = ["boto3", "toolshield", "vertex"] [[package]] name = "openhands-tools" -version = "1.47.0" +version = "1.48.0" source = { editable = "openhands-tools" } dependencies = [ { name = "binaryornot" }, @@ -2907,7 +2907,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.47.0" +version = "1.48.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" },