fix(resource): wire --clone-into into DevcontainerHandler.resolve() runtime path

The --clone-into CLI argument was registered and the helper
clone_repo_into_container() was implemented, but DevcontainerHandler.resolve()
never read the clone_into property or called the helper. This meant that
agents resource add container-instance --clone-into <url> silently ignored
the flag at runtime (acceptance criterion #2 from issue #7555 was unmet).

Wire the clone step into DevcontainerHandler.resolve(): after
activate_container() returns and the lifecycle tracker has a container_id,
validate the URL and call clone_repo_into_container(). Also add an
end-to-end BDD scenario that exercises the full handler to clone path via
mocks.

ISSUES CLOSED: #7555
This commit is contained in:
2026-04-16 00:45:38 +00:00
parent 919891f1d5
commit dc05edb22a
3 changed files with 156 additions and 0 deletions
+8
View File
@@ -82,3 +82,11 @@ Feature: container-instance --clone-into argument
Scenario: validate_clone_into_url rejects whitespace-only string
When I validate clone-into URL with whitespace only
Then the clone-into URL should be invalid
# -- End-to-end handler wiring --
Scenario: DevcontainerHandler.resolve() calls clone_repo_into_container when clone_into property is set
Given a devcontainer-instance resource with clone_into "https://github.com/org/repo.git" and tracker container_id "abc123def456"
And clone_repo_into_container is mocked to succeed
When DevcontainerHandler.resolve() is called for the resource
Then clone_repo_into_container should have been called with container_id "abc123def456" and url "https://github.com/org/repo.git"
@@ -278,3 +278,121 @@ def step_clone_into_not_required(context: Context) -> None:
f"Expected clone-into to be optional (required=False), "
f"got required={clone_into_arg.get('required')}"
)
# -- End-to-end handler wiring steps --
@given(
'a devcontainer-instance resource with clone_into "{clone_url}" and tracker container_id "{container_id}"'
)
def step_devcontainer_resource_with_clone_into(
context: Context, clone_url: str, container_id: str
) -> None:
"""Set up a devcontainer-instance resource with clone_into property and tracker."""
from cleveragents.domain.models.core.container_lifecycle import (
ContainerLifecycleState,
ContainerLifecycleTracker,
)
from cleveragents.domain.models.core.resource import Resource
from cleveragents.resource.handlers.devcontainer import (
clear_lifecycle_registry,
set_lifecycle_tracker,
)
# Clear any existing lifecycle state
clear_lifecycle_registry()
# Create a resource with clone_into in properties
context.handler_resource = Resource(
resource_id="01HANDLER0000000000000001",
name="test-devcontainer",
resource_type_name="devcontainer-instance",
classification="tool",
description="Test devcontainer for clone-into wiring",
location="/tmp/test-workspace",
properties={"clone_into": clone_url},
)
# Set up the lifecycle tracker in DISCOVERED state with container_id pre-set
# (simulates the state after activate_container() has run and set container_id)
tracker = ContainerLifecycleTracker(
resource_id="01HANDLER0000000000000001",
current_state=ContainerLifecycleState.DISCOVERED,
container_id=container_id,
)
set_lifecycle_tracker(tracker)
context.handler_clone_url = clone_url
context.handler_container_id = container_id
# Store cleanup reference
context.add_cleanup(clear_lifecycle_registry)
@given("clone_repo_into_container is mocked to succeed")
def step_mock_clone_repo_into_container(context: Context) -> None:
"""Mock clone_repo_into_container to capture calls without running docker."""
mock_clone = MagicMock(return_value="/workspace")
patcher = patch(
"cleveragents.resource.handlers.devcontainer.clone_repo_into_container",
mock_clone,
)
patcher.start()
context.mock_clone_repo = mock_clone
context.add_cleanup(patcher.stop)
@when("DevcontainerHandler.resolve() is called for the resource")
def step_call_devcontainer_handler_resolve(context: Context) -> None:
"""Call DevcontainerHandler.resolve() with the test resource."""
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
handler = DevcontainerHandler()
mock_sandbox_manager = MagicMock()
mock_bound_resource = MagicMock()
# Patch activate_container to be a no-op (tracker already has container_id)
# Patch BaseResourceHandler.resolve to return a mock BoundResource
with patch(
"cleveragents.resource.handlers.devcontainer.activate_container"
), patch(
"cleveragents.resource.handlers._base.BaseResourceHandler.resolve",
return_value=mock_bound_resource,
):
context.handler_resolve_error = None
context.handler_resolve_result = None
try:
context.handler_resolve_result = handler.resolve(
resource=context.handler_resource,
plan_id="test-plan-001",
slot_name="test-slot",
sandbox_manager=mock_sandbox_manager,
)
except Exception as exc:
context.handler_resolve_error = exc
@then(
'clone_repo_into_container should have been called with container_id "{expected_container_id}" and url "{expected_url}"'
)
def step_assert_clone_called(
context: Context, expected_container_id: str, expected_url: str
) -> None:
"""Assert that clone_repo_into_container was called with the expected arguments."""
assert context.handler_resolve_error is None, (
f"Expected resolve() to succeed but got error: {context.handler_resolve_error!r}"
)
mock_clone = context.mock_clone_repo
assert mock_clone.called, (
"Expected clone_repo_into_container to have been called, but it was not"
)
call_args = mock_clone.call_args
actual_container_id = call_args[0][0] if call_args[0] else call_args[1].get("container_id")
actual_url = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("repo_url")
assert actual_container_id == expected_container_id, (
f"Expected container_id '{expected_container_id}', got '{actual_container_id}'"
)
assert actual_url == expected_url, (
f"Expected url '{expected_url}', got '{actual_url}'"
)
@@ -72,6 +72,10 @@ from cleveragents.resource.handlers._devcontainer_internals import (
list_active_containers,
set_lifecycle_tracker,
)
from cleveragents.resource.handlers.clone_into import (
clone_repo_into_container,
validate_clone_into_url,
)
from cleveragents.resource.handlers.devcontainer_cleanup import (
evict_terminal_trackers,
list_active_containers_for_session,
@@ -119,6 +123,7 @@ __all__ = [
"_stop_health_check",
"activate_container",
"clear_lifecycle_registry",
"clone_repo_into_container",
"evict_terminal_trackers",
"get_lifecycle_tracker",
"list_active_containers",
@@ -128,6 +133,7 @@ __all__ = [
"start_health_check",
"stop_all_active_containers",
"stop_container",
"validate_clone_into_url",
]
@@ -319,6 +325,10 @@ class DevcontainerHandler(BaseResourceHandler):
base-class sandbox resolution. For other resource types (e.g.
``devcontainer-file``), it delegates directly.
If the resource has a ``clone_into`` property, the specified git
repository is cloned into the container after activation (issue
#7555).
Args:
resource: The resource domain object to resolve.
plan_id: The plan requesting the sandbox.
@@ -343,6 +353,26 @@ class DevcontainerHandler(BaseResourceHandler):
session_id=plan_id,
)
# Issue #7555: wire --clone-into after container is running.
# Read the updated tracker to get the container_id assigned
# during activation, then clone the repository if requested.
if resource.properties:
clone_into_url = resource.properties.get("clone_into")
if clone_into_url and isinstance(clone_into_url, str):
validate_clone_into_url(clone_into_url)
updated_tracker = get_lifecycle_tracker(resource.resource_id)
if updated_tracker.container_id:
logger.info(
"Cloning '%s' into container '%s' (resource %s)",
clone_into_url,
updated_tracker.container_id,
resource.resource_id,
)
clone_repo_into_container(
updated_tracker.container_id,
clone_into_url,
)
return super().resolve(
resource=resource,
plan_id=plan_id,