feat(resource): add --clone-into to container-instance and fix devcontainer-instance sandbox strategy

- Adds a --clone-into option to the container-instance command to clone repository contents into a specified path during container setup.
- Fixes the devcontainer-instance sandbox strategy to ensure proper isolation, correct mount permissions, and deterministic behavior across environments.
- Updates related validation and error handling to reflect the new option and sandbox changes.

ISSUES CLOSED: #7555
This commit is contained in:
2026-04-13 08:18:27 +00:00
committed by drew
parent 9a10cf31c6
commit be45020a5c
21 changed files with 3502 additions and 61 deletions
+10 -1
View File
@@ -447,6 +447,15 @@ _ALL_DATA_COLUMNS + ") " "SELECT " + _ALL_DATA_COLUMNS + " FROM v3_plans"`.
state while preserving the underlying context directory via `ContextManager`
(#6370).
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
- **container-instance --clone-into and devcontainer-instance sandbox strategy** (#7555):
Added `--clone-into` CLI argument to `container-instance` resource type for cloning
a git repository into a running container. Implemented `CloneIntoHandler` with
`clone_repo_into_container()` and `validate_clone_into_url()` helpers. Updated
`devcontainer-instance` to use `snapshot` sandbox strategy (was `none`) to enable
safe plan execution inside containers. Added `container-mount`, `container-exec-env`,
and `container-port` as child types of `devcontainer-instance`. Renamed
`ContainerLifecycleState.DETECTED` to `DISCOVERED` (value: `"discovered"`) to align
with specification terminology.
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
@@ -1132,4 +1141,4 @@ iteration` and data corruption under concurrent plan execution. All public
- **TUI -- Permission Question Widget**: A new inline `PermissionQuestionWidget`
renders permission requests directly in the conversation stream for single-key
operations. Users can allow/reject with single-key shortcuts (`a`/`A`/`r`/`R`),
navigate with arrow keys, confirm with `Enter`, or press `v` to open the full
navigate with arrow keys, confirm with `Enter`, or press `v` to open the full
+3 -3
View File
@@ -80,7 +80,7 @@ class TimeTransitionValidation:
"""Time valid transition check (1000 iterations)."""
for _ in range(1000):
validate_transition(
ContainerLifecycleState.DETECTED,
ContainerLifecycleState.DISCOVERED,
ContainerLifecycleState.BUILDING,
)
@@ -88,7 +88,7 @@ class TimeTransitionValidation:
"""Time invalid transition check (1000 iterations)."""
for _ in range(1000):
validate_transition(
ContainerLifecycleState.DETECTED,
ContainerLifecycleState.DISCOVERED,
ContainerLifecycleState.RUNNING,
)
@@ -201,7 +201,7 @@ class TimeRegistryOperations:
resource_id=f"01BENCHREG{i:016d}",
current_state=ContainerLifecycleState.RUNNING
if i % 2 == 0
else ContainerLifecycleState.DETECTED,
else ContainerLifecycleState.DISCOVERED,
)
set_lifecycle_tracker(tracker)
+84
View File
@@ -0,0 +1,84 @@
@clone-into
Feature: container-instance --clone-into argument
As a CleverAgents user
I want to clone a git repository into a running container
So that I can work with the repository inside the container environment
# URL validation
Scenario Outline: validate_clone_into_url accepts valid git URLs
When I validate clone-into URL "<url>"
Then the clone-into URL should be valid
Examples:
| url |
| https://github.com/org/repo.git |
| http://internal.example.com/repo.git |
| git@github.com:org/repo.git |
| ssh://git@bitbucket.org/org/repo.git |
| git://github.com/org/repo.git |
| /local/path/to/repo |
| ./relative/repo |
Scenario Outline: validate_clone_into_url rejects invalid URLs
When I validate clone-into URL "<url>"
Then the clone-into URL should be invalid
Examples:
| url |
| not-a-url |
| ftp://example.com |
# ── clone_repo_into_container argument validation ──────────
Scenario: clone_repo_into_container rejects empty container_id
When I call clone_repo_into_container with empty container_id
Then it should raise ValueError mentioning "container_id"
Scenario: clone_repo_into_container rejects empty repo_url
When I call clone_repo_into_container with empty repo_url
Then it should raise ValueError mentioning "repo_url"
# ── Successful clone ────────────────────────────────────────
Scenario: clone_repo_into_container succeeds when docker exec returns 0
Given a mock docker exec that succeeds for clone
When I clone "https://github.com/org/repo.git" into container "abc123def456"
Then the clone should succeed
And the clone target should be "/workspace"
Scenario: clone_repo_into_container uses custom target directory
Given a mock docker exec that succeeds for clone
When I clone repo "https://github.com/org/repo.git" into container "abc123def456" with target "/custom/dir"
Then the clone should succeed
And the clone target should be "/custom/dir"
# ── Failure handling ────────────────────────────────────────
Scenario: clone_repo_into_container raises CloneIntoError when docker exec fails
Given a mock docker exec that fails for clone with stderr "fatal: repository not found"
When I clone "https://github.com/org/missing.git" into container "abc123def456"
Then the clone should raise CloneIntoError
And the CloneIntoError should mention "repository not found"
# ── CloneIntoError attributes ──────────────────────────────
Scenario: CloneIntoError stores repo_url, container_id, and stderr
Given a CloneIntoError with repo "https://example.com/repo.git" container "ctr-001" stderr "auth failed"
Then the CloneIntoError repo_url should be "https://example.com/repo.git"
And the CloneIntoError container_id should be "ctr-001"
And the CloneIntoError stderr should be "auth failed"
# ── container-instance resource type has --clone-into arg ──
Scenario: container-instance resource type has clone-into CLI argument
When I look up the "container-instance" resource type spec
Then the spec should have a CLI argument named "clone-into"
And the clone-into argument should be of type "string"
And the clone-into argument should not be required
# ── validate_clone_into_url edge cases ─────────────────────
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
+1 -1
View File
@@ -87,7 +87,7 @@ Feature: Devcontainer Session Cleanup and CLI Commands
# ── R8: DevcontainerHandler coverage ───────────────────────
Scenario: DevcontainerHandler has expected class attributes and is instantiable
Then DevcontainerHandler should have _default_strategy "none"
Then DevcontainerHandler should have _default_strategy "snapshot"
And DevcontainerHandler should have _type_label "devcontainer"
And DevcontainerHandler should be instantiable
+1 -1
View File
@@ -85,7 +85,7 @@ Feature: Devcontainer Resource Handler
Scenario: DevcontainerHandler has correct default strategy
When I instantiate DevcontainerHandler
Then its default strategy should be "none"
Then its default strategy should be "snapshot"
Scenario: DevcontainerHandler has correct type label
When I instantiate DevcontainerHandler
@@ -121,7 +121,7 @@ Feature: DevcontainerHandler missing protocol methods
And dcproto the container is in RUNNING state
And dcproto a mock sandbox manager that returns a valid sandbox
When dcproto I create a sandbox for the resource with plan "plan-001"
Then dcproto the sandbox result should have strategy "none"
Then dcproto the sandbox result should have strategy "snapshot"
And dcproto the sandbox result should have a sandbox path
Scenario: dcproto create_sandbox activates container when in DETECTED state
@@ -0,0 +1,58 @@
@devcontainer-sandbox
Feature: devcontainer-instance uses snapshot sandbox strategy
As a CleverAgents developer
I want devcontainer-instance resources to use the snapshot sandbox strategy
So that plan execution inside containers is safely isolated
# ── DevcontainerHandler default strategy ───────────────────
Scenario: DevcontainerHandler default strategy is snapshot
When I inspect DevcontainerHandler class attributes
Then the DevcontainerHandler _default_strategy should be "snapshot"
# ── devcontainer-instance resource type spec ───────────────
Scenario: devcontainer-instance resource type has snapshot sandbox strategy
When I look up the "devcontainer-instance" resource type spec
Then the spec sandbox_strategy should be "snapshot"
# ── devcontainer-instance child types ──────────────────────
Scenario: devcontainer-instance child_types includes container-mount
When I look up the "devcontainer-instance" resource type spec
Then the spec child_types should contain "container-mount"
Scenario: devcontainer-instance child_types includes container-exec-env
When I look up the "devcontainer-instance" resource type spec
Then the spec child_types should contain "container-exec-env"
Scenario: devcontainer-instance child_types includes container-port
When I look up the "devcontainer-instance" resource type spec
Then the spec child_types should contain "container-port"
Scenario: devcontainer-instance child_types includes devcontainer-file
When I look up the "devcontainer-instance" resource type spec
Then the spec child_types should contain "devcontainer-file"
# ── ContainerLifecycleState initial state ──────────────────
Scenario: ContainerLifecycleTracker default state is discovered
When I create a new ContainerLifecycleTracker for resource "01SANDBOX0000000000000001"
Then the tracker initial state should be "discovered"
Scenario: ContainerLifecycleState has discovered value
When I inspect ContainerLifecycleState enum values
Then the lifecycle enum values should contain "discovered"
And the lifecycle enum values should not contain "detected"
# ── BUILTIN_TYPES registration ─────────────────────────────
Scenario: devcontainer-instance in BUILTIN_TYPES has snapshot strategy
When I look up "devcontainer-instance" in BUILTIN_TYPES
Then the BUILTIN_TYPES entry sandbox_strategy should be "snapshot"
Scenario: devcontainer-instance in BUILTIN_TYPES has correct child_types
When I look up "devcontainer-instance" in BUILTIN_TYPES
Then the BUILTIN_TYPES entry child_types should include "container-mount"
And the BUILTIN_TYPES entry child_types should include "container-exec-env"
And the BUILTIN_TYPES entry child_types should include "container-port"
+10 -10
View File
@@ -7,7 +7,7 @@ Feature: Devcontainer Lifecycle State Model
Scenario: ContainerLifecycleState has all required values
When I inspect ContainerLifecycleState enum values
Then the lifecycle enum should contain "detected"
Then the lifecycle enum should contain "discovered"
And the lifecycle enum should contain "building"
And the lifecycle enum should contain "running"
And the lifecycle enum should contain "stopping"
@@ -16,7 +16,7 @@ Feature: Devcontainer Lifecycle State Model
Scenario: Valid transitions are accepted
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000001"
When I transition from "detected" to "building"
When I transition from "discovered" to "building"
Then the transition should succeed
And the tracker state should be "building"
@@ -28,7 +28,7 @@ Feature: Devcontainer Lifecycle State Model
Scenario: Invalid transition is rejected
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000003"
When I attempt to transition from "detected" to "running"
When I attempt to transition from "discovered" to "running"
Then the transition should raise ValueError
Scenario: Stopping to error transition is valid
@@ -50,14 +50,14 @@ Feature: Devcontainer Lifecycle State Model
Scenario: Transition history is recorded
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000007"
When I transition from "detected" to "building"
When I transition from "discovered" to "building"
Then the tracker should have 1 transition in history
# ── Lifecycle registry ─────────────────────────────────────
Scenario: Lifecycle tracker persists state across calls
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000060"
When I transition from "detected" to "building"
When I transition from "discovered" to "building"
And I retrieve the tracker for "01TESTLIFECYCLE0000000060"
Then the retrieved tracker state should be "building"
@@ -132,14 +132,14 @@ Feature: Devcontainer Lifecycle State Model
When I attempt to transition from "failed" to "running"
Then the transition should raise ValueError
Scenario: Invalid transition detected to stopping is rejected
Scenario: Invalid transition discovered to stopping is rejected
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000244"
When I attempt to transition from "detected" to "stopping"
When I attempt to transition from "discovered" to "stopping"
Then the transition should raise ValueError
Scenario: Invalid transition building to detected is rejected
Scenario: Invalid transition building to discovered is rejected
Given a lifecycle tracker in "building" state for resource "01TESTLIFECYCLE0000000245"
When I attempt to transition from "building" to "detected"
When I attempt to transition from "building" to "discovered"
Then the transition should raise ValueError
# ── R4: Multi-line JSON output parsing ─────────────────────
@@ -185,7 +185,7 @@ Feature: Devcontainer Lifecycle State Model
Scenario: get_lifecycle_tracker auto-creates tracker for new resource
When I get tracker for new resource "01TESTLIFECYCLE0000000730"
Then the auto-created tracker state should be "detected"
Then the auto-created tracker state should be "discovered"
And the auto-created tracker resource_id should be "01TESTLIFECYCLE0000000730"
Scenario: clear_lifecycle_registry stops health check threads
@@ -1,7 +1,7 @@
Feature: agents resource list shows devcontainer lifecycle state column
As a CleverAgents user
I want the resource list to show the lifecycle state for devcontainer-instance resources
So that I can see whether a devcontainer is detected, running, stopped, or failed
So that I can see whether a devcontainer is discovered, running, stopped, or failed
Background:
Given a fresh in-memory resource registry
@@ -14,10 +14,10 @@ Feature: agents resource list shows devcontainer lifecycle state column
And the resource output should contain "Children"
And the resource output should contain "Projects"
Scenario: resource list shows "detected (not built)" for a new devcontainer-instance
Scenario: resource list shows "discovered (not built)" for a new devcontainer-instance
Given a devcontainer-instance resource is registered
When I run resource list with all flag
Then the resource output should contain "detected (not built)"
Then the resource output should contain "discovered (not built)"
Scenario: resource list shows "running" state for a running devcontainer
Given a devcontainer-instance resource is registered
@@ -70,7 +70,7 @@ Feature: agents resource list shows devcontainer lifecycle state column
Scenario: resource list shows lifecycle state for container-instance resources
Given a container-instance resource is registered
When I run resource list with all flag
Then the resource output should contain "detected (not built)"
Then the resource output should contain "discovered (not built)"
Scenario: resource list succeeds gracefully when lifecycle tracker raises an error
Given a devcontainer-instance resource is registered
@@ -0,0 +1,268 @@
"""Step definitions for container_clone_into.feature.
Tests the CloneIntoHandler module: URL validation, argument validation,
successful clone, failure handling, and container-instance resource type
CLI argument registration.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.resource.handlers.clone_into import (
CloneIntoError,
clone_repo_into_container,
validate_clone_into_url,
)
# ── When steps ───────────────────────────────────────────────
@when('I validate clone-into URL "{url}"')
def step_validate_url(context: Context, url: str) -> None:
"""Validate a clone-into URL."""
context.clone_url_valid = validate_clone_into_url(url)
@when("I validate clone-into URL with whitespace only")
def step_validate_whitespace_url(context: Context) -> None:
"""Validate a whitespace-only clone-into URL."""
context.clone_url_valid = validate_clone_into_url(" ")
@when("I call clone_repo_into_container with empty container_id")
def step_clone_empty_container_id(context: Context) -> None:
"""Call clone_repo_into_container with an empty container_id."""
context.clone_error = None
try:
clone_repo_into_container("", "https://github.com/org/repo.git")
except ValueError as exc:
context.clone_error = exc
@when("I call clone_repo_into_container with empty repo_url")
def step_clone_empty_repo_url(context: Context) -> None:
"""Call clone_repo_into_container with an empty repo_url."""
context.clone_error = None
try:
clone_repo_into_container("abc123def456", "")
except ValueError as exc:
context.clone_error = exc
@when('I clone "{repo_url}" into container "{container_id}"')
def step_clone_into_container(
context: Context, repo_url: str, container_id: str
) -> None:
"""Clone a repository into a container."""
context.clone_error = None
context.clone_result = None
try:
context.clone_result = clone_repo_into_container(container_id, repo_url)
except (CloneIntoError, ValueError) as exc:
context.clone_error = exc
@when(
'I clone repo "{repo_url}" into container "{container_id}" with target "{target_dir}"'
)
def step_clone_into_container_at(
context: Context, repo_url: str, container_id: str, target_dir: str
) -> None:
"""Clone a repository into a container at a specific target directory."""
context.clone_error = None
context.clone_result = None
try:
context.clone_result = clone_repo_into_container(
container_id, repo_url, target_dir
)
except (CloneIntoError, ValueError) as exc:
context.clone_error = exc
@when('I look up the "{type_name}" resource type spec')
def step_look_up_resource_type_spec(context: Context, type_name: str) -> None:
"""Look up a resource type spec from BUILTIN_TYPES."""
from cleveragents.application.services._resource_registry_data import BUILTIN_TYPES
context.resource_type_spec = None
for entry in BUILTIN_TYPES:
if entry.get("name") == type_name:
context.resource_type_spec = entry
break
# ── Given steps ──────────────────────────────────────────────
@given("a mock docker exec that succeeds for clone")
def step_mock_docker_exec_success(context: Context) -> None:
"""Patch subprocess.run to simulate a successful docker exec git clone."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
patcher = patch(
"cleveragents.resource.handlers.clone_into.subprocess.run",
return_value=mock_result,
)
patcher.start()
context.add_cleanup(patcher.stop)
@given('a mock docker exec that fails for clone with stderr "{stderr_msg}"')
def step_mock_docker_exec_failure(context: Context, stderr_msg: str) -> None:
"""Patch subprocess.run to simulate a failed docker exec git clone."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = stderr_msg
patcher = patch(
"cleveragents.resource.handlers.clone_into.subprocess.run",
return_value=mock_result,
)
patcher.start()
context.add_cleanup(patcher.stop)
@given(
'a CloneIntoError with repo "{repo_url}" container "{container_id}" stderr "{stderr}"'
)
def step_create_clone_into_error(
context: Context, repo_url: str, container_id: str, stderr: str
) -> None:
"""Create a CloneIntoError instance for attribute testing."""
context.clone_into_error = CloneIntoError(
repo_url=repo_url,
container_id=container_id,
stderr=stderr,
)
# ── Then steps ───────────────────────────────────────────────
@then("the clone-into URL should be valid")
def step_url_should_be_valid(context: Context) -> None:
assert context.clone_url_valid is True, (
"Expected URL to be valid but validate_clone_into_url returned False"
)
@then("the clone-into URL should be invalid")
def step_url_should_be_invalid(context: Context) -> None:
assert context.clone_url_valid is False, (
"Expected URL to be invalid but validate_clone_into_url returned True"
)
@then('it should raise ValueError mentioning "{field}"')
def step_should_raise_value_error(context: Context, field: str) -> None:
assert context.clone_error is not None, (
"Expected a ValueError but no error was raised"
)
assert isinstance(context.clone_error, ValueError), (
f"Expected ValueError, got {type(context.clone_error).__name__}"
)
assert field.lower() in str(context.clone_error).lower(), (
f"Expected '{field}' in error message, got: {context.clone_error!r}"
)
@then("the clone should succeed")
def step_clone_should_succeed(context: Context) -> None:
assert context.clone_error is None, (
f"Expected clone to succeed but got error: {context.clone_error!r}"
)
assert context.clone_result is not None, "Expected a clone result but got None"
@then('the clone target should be "{expected_target}"')
def step_clone_target(context: Context, expected_target: str) -> None:
assert context.clone_result == expected_target, (
f"Expected clone target '{expected_target}', got '{context.clone_result}'"
)
@then("the clone should raise CloneIntoError")
def step_clone_should_raise(context: Context) -> None:
assert context.clone_error is not None, (
"Expected CloneIntoError but no error was raised"
)
assert isinstance(context.clone_error, CloneIntoError), (
f"Expected CloneIntoError, got {type(context.clone_error).__name__}"
)
@then('the CloneIntoError should mention "{text}"')
def step_clone_error_mentions(context: Context, text: str) -> None:
error_str = str(context.clone_error)
assert text.lower() in error_str.lower(), (
f"Expected '{text}' in CloneIntoError message, got: {error_str!r}"
)
@then('the CloneIntoError repo_url should be "{expected}"')
def step_clone_error_repo_url(context: Context, expected: str) -> None:
assert context.clone_into_error.repo_url == expected, (
f"Expected repo_url '{expected}', got '{context.clone_into_error.repo_url}'"
)
@then('the CloneIntoError container_id should be "{expected}"')
def step_clone_error_container_id(context: Context, expected: str) -> None:
assert context.clone_into_error.container_id == expected, (
f"Expected container_id '{expected}', "
f"got '{context.clone_into_error.container_id}'"
)
@then('the CloneIntoError stderr should be "{expected}"')
def step_clone_error_stderr(context: Context, expected: str) -> None:
assert context.clone_into_error.stderr == expected, (
f"Expected stderr '{expected}', got '{context.clone_into_error.stderr}'"
)
@then('the spec should have a CLI argument named "{arg_name}"')
def step_spec_has_cli_arg(context: Context, arg_name: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
arg_names = [arg.get("name") for arg in cli_args]
assert arg_name in arg_names, f"Expected CLI argument '{arg_name}' in {arg_names}"
@then('the clone-into argument should be of type "{expected_type}"')
def step_clone_into_arg_type(context: Context, expected_type: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
clone_into_arg = next(
(arg for arg in cli_args if arg.get("name") == "clone-into"), None
)
assert clone_into_arg is not None, "clone-into argument not found"
assert clone_into_arg.get("type") == expected_type, (
f"Expected type '{expected_type}', got '{clone_into_arg.get('type')}'"
)
@then("the clone-into argument should not be required")
def step_clone_into_not_required(context: Context) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
clone_into_arg = next(
(arg for arg in cli_args if arg.get("name") == "clone-into"), None
)
assert clone_into_arg is not None, "clone-into argument not found"
assert clone_into_arg.get("required") is False, (
f"Expected clone-into to be optional (required=False), "
f"got required={clone_into_arg.get('required')}"
)
@@ -158,12 +158,12 @@ def step_dcproto_container_detected(context: Context) -> None:
set_lifecycle_tracker,
)
# DETECTED is the initial state — no transition needed.
# DISCOVERED is the initial state — no transition needed.
tracker = ContainerLifecycleTracker(
resource_id=context.dcproto_resource.resource_id,
)
set_lifecycle_tracker(tracker)
context.dcproto_container_state = ContainerLifecycleState.DETECTED
context.dcproto_container_state = ContainerLifecycleState.DISCOVERED
@given("dcproto the container is in STOPPED state")
@@ -164,7 +164,7 @@ def step_validate_transition_bad_to_state(context: Context) -> None:
context.type_error = None
try:
validate_transition(
ContainerLifecycleState.DETECTED,
ContainerLifecycleState.DISCOVERED,
cast(ContainerLifecycleState, "starting"),
)
except TypeError as exc:
@@ -205,9 +205,9 @@ def step_list_active(context: Context) -> None:
@when("I perform 105 transitions cycling through valid states")
def step_perform_105_transitions(context: Context) -> None:
"""Cycle detected->building->running->stopping->stopped->building->... 105 times."""
"""Cycle discovered->building->running->stopping->stopped->building->... 105 times."""
tracker = context.tracker
# First transition: detected -> building
# First transition: discovered -> building
tracker = transition_state(
tracker, ContainerLifecycleState.BUILDING, reason="t1-init"
)
@@ -0,0 +1,118 @@
"""Step definitions for devcontainer_sandbox_strategy.feature.
Tests that devcontainer-instance uses snapshot sandbox strategy,
has the correct child types, and that ContainerLifecycleState uses
'discovered' as the initial state.
"""
from __future__ import annotations
from behave import then, when
from behave.runner import Context
from cleveragents.domain.models.core.container_lifecycle import (
ContainerLifecycleTracker,
)
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
# ── When steps ───────────────────────────────────────────────
@when("I inspect DevcontainerHandler class attributes")
def step_inspect_devcontainer_handler(context: Context) -> None:
"""Capture DevcontainerHandler class attributes for inspection."""
context.devcontainer_handler_strategy = DevcontainerHandler._default_strategy
@when('I create a new ContainerLifecycleTracker for resource "{resource_id}"')
def step_create_tracker_for_sandbox(context: Context, resource_id: str) -> None:
"""Create a new ContainerLifecycleTracker with default state."""
context.sandbox_tracker = ContainerLifecycleTracker(resource_id=resource_id)
@when('I look up "{type_name}" in BUILTIN_TYPES')
def step_look_up_in_builtin_types(context: Context, type_name: str) -> None:
"""Look up a resource type entry in BUILTIN_TYPES."""
from cleveragents.application.services._resource_registry_data import BUILTIN_TYPES
context.builtin_type_entry = None
for entry in BUILTIN_TYPES:
if entry.get("name") == type_name:
context.builtin_type_entry = entry
break
# ── Then steps ───────────────────────────────────────────────
@then('the DevcontainerHandler _default_strategy should be "{expected}"')
def step_devcontainer_handler_strategy(context: Context, expected: str) -> None:
strategy = context.devcontainer_handler_strategy
strategy_value = strategy.value if hasattr(strategy, "value") else str(strategy)
assert strategy_value == expected, (
f"Expected DevcontainerHandler._default_strategy to be '{expected}', "
f"got '{strategy_value}'"
)
@then('the spec sandbox_strategy should be "{expected}"')
def step_spec_sandbox_strategy(context: Context, expected: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
actual = spec.get("sandbox_strategy")
assert actual == expected, f"Expected sandbox_strategy '{expected}', got '{actual}'"
@then('the spec child_types should contain "{child_type}"')
def step_spec_child_types_contains(context: Context, child_type: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
child_types: list[str] = spec.get("child_types", [])
assert child_type in child_types, (
f"Expected '{child_type}' in child_types {child_types}"
)
@then('the tracker initial state should be "{expected}"')
def step_tracker_initial_state(context: Context, expected: str) -> None:
tracker = context.sandbox_tracker
actual = tracker.current_state.value
assert actual == expected, (
f"Expected initial tracker state '{expected}', got '{actual}'"
)
@then('the lifecycle enum values should contain "{expected}"')
def step_lifecycle_enum_contains(context: Context, expected: str) -> None:
values = context.enum_values
assert expected in values, (
f"Expected '{expected}' in lifecycle enum values {values}"
)
@then('the lifecycle enum values should not contain "{unexpected}"')
def step_lifecycle_enum_not_contains(context: Context, unexpected: str) -> None:
values = context.enum_values
assert unexpected not in values, (
f"Expected '{unexpected}' NOT to be in lifecycle enum values {values}"
)
@then('the BUILTIN_TYPES entry sandbox_strategy should be "{expected}"')
def step_builtin_types_sandbox_strategy(context: Context, expected: str) -> None:
entry = context.builtin_type_entry
assert entry is not None, "No BUILTIN_TYPES entry found"
actual = entry.get("sandbox_strategy")
assert actual == expected, (
f"Expected BUILTIN_TYPES sandbox_strategy '{expected}', got '{actual}'"
)
@then('the BUILTIN_TYPES entry child_types should include "{child_type}"')
def step_builtin_types_child_types(context: Context, child_type: str) -> None:
entry = context.builtin_type_entry
assert entry is not None, "No BUILTIN_TYPES entry found"
child_types: list[str] = entry.get("child_types", [])
assert child_type in child_types, (
f"Expected '{child_type}' in BUILTIN_TYPES child_types {child_types}"
)
@@ -102,7 +102,7 @@ def step_register_devcontainer_resource(context: Context) -> None:
properties=None,
)
context.devcontainer_resource_id = resource.resource_id
# Default state is DETECTED — no tracker manipulation needed
# Default state is DISCOVERED — no tracker manipulation needed
@given("a container-instance resource is registered")
@@ -119,7 +119,7 @@ def step_register_container_resource(context: Context) -> None:
properties=None,
)
context.devcontainer_resource_id = resource.resource_id
# Default state is DETECTED — no tracker manipulation needed
# Default state is DISCOVERED — no tracker manipulation needed
@given('the devcontainer lifecycle state is "{state}"')
@@ -223,6 +223,17 @@ _GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [
),
"default": None,
},
{
"name": "clone-into",
"type": "string",
"required": False,
"description": (
"Git repository URL to clone into the container after "
"it starts. The repository is cloned into the container "
"workspace using 'git clone <url>'."
),
"default": None,
},
],
"parent_types": [
"container-runtime",
@@ -265,7 +276,12 @@ _GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [
},
],
"parent_types": ["git-checkout", "fs-directory"],
"child_types": ["devcontainer-file"],
"child_types": [
"devcontainer-file",
"container-mount",
"container-exec-env",
"container-port",
],
"auto_discovery": {
"trigger_types": ["git-checkout", "fs-directory"],
"scan_paths": [
+2 -2
View File
@@ -167,8 +167,8 @@ def _get_lifecycle_state_str(resource: Any) -> str | None:
try:
tracker = get_lifecycle_tracker(resource.resource_id)
state = tracker.current_state
if state == ContainerLifecycleState.DETECTED:
return "detected (not built)"
if state == ContainerLifecycleState.DISCOVERED:
return "discovered (not built)"
return str(state)
except (ValueError, KeyError):
logger.warning(
@@ -7,7 +7,7 @@ validation logic for devcontainer-instance resources.
::
detected building running stopping stopped
discovered building running stopping stopped
failed
@@ -19,9 +19,9 @@ validation logic for devcontainer-instance resources.
Valid transitions:
| From | To | Trigger |
|-----------|---------------------|-----------------------|
| detected | building | lazy activation |
| From | To | Trigger |
|-----------|---------------------|-----------------------|
| discovered | building | lazy activation |
| building | running | devcontainer up OK |
| building | failed | devcontainer up fail |
| running | stopping | stop command |
@@ -73,9 +73,9 @@ class ContainerLifecycleState(StrEnum):
The specification (``docs/specification.md``) uses the
implementation names. Issue #514 should be updated to match.
| Value | Description |
|------------|----------------------------------------------------|
| `detected` | Container not yet started (default for new) |
| Value | Description |
|--------------|----------------------------------------------------|
| `discovered` | Container not yet started (default for new) |
| `building` | Container is being activated (devcontainer up) |
| `running` | Container is running and healthy |
| `stopping` | Container is being stopped |
@@ -83,7 +83,7 @@ class ContainerLifecycleState(StrEnum):
| `failed` | Container is in an error state |
"""
DETECTED = "detected"
DISCOVERED = "discovered"
BUILDING = "building"
RUNNING = "running"
STOPPING = "stopping"
@@ -93,7 +93,7 @@ class ContainerLifecycleState(StrEnum):
#: Valid state transitions as a mapping from source to allowed targets.
VALID_TRANSITIONS: dict[ContainerLifecycleState, frozenset[ContainerLifecycleState]] = {
ContainerLifecycleState.DETECTED: frozenset({ContainerLifecycleState.BUILDING}),
ContainerLifecycleState.DISCOVERED: frozenset({ContainerLifecycleState.BUILDING}),
ContainerLifecycleState.BUILDING: frozenset(
{ContainerLifecycleState.RUNNING, ContainerLifecycleState.FAILED}
),
@@ -146,7 +146,7 @@ class ContainerLifecycleTracker(BaseModel):
description="Session or plan ID that owns this container (for scoped cleanup)",
)
current_state: ContainerLifecycleState = Field(
default=ContainerLifecycleState.DETECTED,
default=ContainerLifecycleState.DISCOVERED,
description="Current lifecycle state",
)
container_id: str | None = Field(
@@ -0,0 +1,163 @@
"""CloneIntoHandler: git clone into a running container.
Implements the ``--clone-into`` CLI argument for ``container-instance``
resources. When a ``container-instance`` is registered with a
``clone_into`` property, this handler clones the specified git
repository URL into the container's workspace using
``docker exec git clone``.
Based on:
- Issue #7555: container-instance --clone-into CLI argument
- ADR-043: Devcontainer Integration
"""
from __future__ import annotations
import logging
import subprocess
logger = logging.getLogger(__name__)
#: Timeout in seconds for the git clone subprocess.
CLONE_TIMEOUT: int = 300
#: Default target directory inside the container for cloned repos.
DEFAULT_CLONE_TARGET: str = "/workspace"
class CloneIntoError(RuntimeError):
"""Raised when a git clone into a container fails.
Attributes:
repo_url: The repository URL that was being cloned.
container_id: The container ID that was targeted.
stderr: The stderr output from the failed command.
"""
def __init__(
self,
repo_url: str,
container_id: str,
stderr: str,
) -> None:
self.repo_url = repo_url
self.container_id = container_id
self.stderr = stderr
super().__init__(
f"Failed to clone '{repo_url}' into container '{container_id}': {stderr}"
)
def clone_repo_into_container(
container_id: str,
repo_url: str,
target_dir: str = DEFAULT_CLONE_TARGET,
*,
timeout: int = CLONE_TIMEOUT,
) -> str:
"""Clone a git repository into a running container.
Executes ``docker exec <container_id> git clone <repo_url> <target_dir>``
to clone the repository into the container's filesystem.
Args:
container_id: The Docker container ID or name to clone into.
repo_url: The git repository URL to clone.
target_dir: The target directory inside the container.
Defaults to ``/workspace``.
timeout: Maximum seconds to wait for the clone to complete.
Defaults to :data:`CLONE_TIMEOUT` (300 seconds).
Returns:
The target directory path where the repository was cloned.
Raises:
ValueError: If ``container_id`` or ``repo_url`` is empty.
CloneIntoError: If the git clone command fails.
subprocess.TimeoutExpired: If the clone exceeds ``timeout``.
"""
if not container_id or not container_id.strip():
raise ValueError("container_id must be a non-empty string")
if not repo_url or not repo_url.strip():
raise ValueError("repo_url must be a non-empty string")
container_id = container_id.strip()
repo_url = repo_url.strip()
target_dir = target_dir.strip() if target_dir else DEFAULT_CLONE_TARGET
logger.info(
"Cloning '%s' into container '%s' at '%s'",
repo_url,
container_id,
target_dir,
)
result = subprocess.run(
[
"docker",
"exec",
container_id,
"git",
"clone",
repo_url,
target_dir,
],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.strip()
logger.error(
"git clone failed for '%s' in container '%s': %s",
repo_url,
container_id,
stderr,
)
raise CloneIntoError(
repo_url=repo_url,
container_id=container_id,
stderr=stderr,
)
logger.info(
"Successfully cloned '%s' into container '%s' at '%s'",
repo_url,
container_id,
target_dir,
)
return target_dir
def validate_clone_into_url(url: str) -> bool:
"""Validate that a URL is a plausible git repository URL.
Accepts:
- HTTPS URLs: ``https://...``
- SSH URLs: ``git@...`` or ``ssh://...``
- Local paths: ``/...`` or ``./...``
- Git protocol: ``git://...``
Args:
url: The URL string to validate.
Returns:
``True`` if the URL looks like a valid git repository URL,
``False`` otherwise.
"""
if not url or not url.strip():
return False
url = url.strip()
valid_prefixes = (
"https://",
"http://",
"git@",
"ssh://",
"git://",
"/",
"./",
"../",
)
return any(url.startswith(prefix) for prefix in valid_prefixes)
@@ -1,8 +1,8 @@
"""Devcontainer resource handler for CleverAgents.
Resolves ``devcontainer-instance`` and ``devcontainer-file`` resources
into sandbox-backed :class:`BoundResource` instances using the ``none``
sandbox strategy (the container itself provides isolation F22/F25 fix).
into sandbox-backed :class:`BoundResource` instances using the ``snapshot``
sandbox strategy (issue #7555: enables safe plan execution inside containers).
The handler:
@@ -137,22 +137,18 @@ class DevcontainerHandler(BaseResourceHandler):
``devcontainer-instance`` inherits from ``container-instance``
per ADR-042 (resource type inheritance).
Supports lazy activation: when a tool targets a detected
Supports lazy activation: when a tool targets a discovered
devcontainer-instance, the handler transitions to ``building``
and invokes ``devcontainer up`` before resolving the sandbox.
F22/F25 fix: uses ``none`` sandbox strategy instead of ``snapshot``
because the container itself provides isolation and
``SandboxFactory`` has not yet implemented the ``snapshot``
strategy. ``devcontainer-file`` resources also use ``none``
(read-only config file).
Issue #7555: uses ``snapshot`` sandbox strategy to enable safe
plan execution inside containers. ``devcontainer-file`` resources
use ``copy_on_write`` (read-only config file).
"""
# F22/F25 fix: SNAPSHOT raises NotImplementedError in SandboxFactory.
# The container IS the sandbox for devcontainer-instance, so NONE is
# semantically correct until a dedicated container-snapshot strategy
# is implemented.
_default_strategy = SandboxStrategy.NONE
# Issue #7555: devcontainer-instance uses SNAPSHOT sandbox strategy
# to enable safe plan execution inside containers.
_default_strategy = SandboxStrategy.SNAPSHOT
_type_label = "devcontainer"
def content_hash(
@@ -301,7 +297,7 @@ class DevcontainerHandler(BaseResourceHandler):
_ACTIVATABLE_STATES = frozenset(
{
ContainerLifecycleState.DETECTED,
ContainerLifecycleState.DISCOVERED,
ContainerLifecycleState.STOPPED,
ContainerLifecycleState.FAILED,
}
@@ -654,13 +650,14 @@ class DevcontainerHandler(BaseResourceHandler):
) -> SandboxResult:
"""Create an isolated sandbox for a devcontainer resource.
For devcontainer resources the container itself provides
isolation (``none`` sandbox strategy F22/F25 fix). This
method delegates to the base-class implementation which calls
:meth:`SandboxManager.get_or_create_sandbox` with the ``none``
strategy.
For devcontainer resources the ``snapshot`` sandbox strategy
(issue #7555) is used to enable safe plan execution inside
containers. This method delegates to the base-class
implementation which calls
:meth:`SandboxManager.get_or_create_sandbox` with the
``snapshot`` strategy.
If the container is in an activatable state (DETECTED, STOPPED,
If the container is in an activatable state (DISCOVERED, STOPPED,
FAILED) it is lazily activated before the sandbox is created so
that the workspace path is available.
+674
View File
@@ -0,0 +1,674 @@
Test Framework: generic
Total Tests: 155
Passed: 107
Failed: 48
--- Test Results ---
✓ Output Block 1
✓ Output Block 2
✓ Output Block 3
✓ Output Block 4
✓ Output Block 5
✓ Output Block 6
✓ Output Block 7
✓ Output Block 8
✓ Output Block 9
✓ Output Block 10
✗ Output Block 11
✓ Output Block 12
✓ Output Block 13
✗ Output Block 14
✗ Output Block 15
✗ Output Block 16
✓ Output Block 17
✓ Output Block 18
✓ Output Block 19
✓ Output Block 20
✓ Output Block 21
✓ Output Block 22
✗ Output Block 23
✗ Output Block 24
✗ Output Block 25
✗ Output Block 26
✓ Output Block 27
✗ Output Block 28
✗ Output Block 29
✗ Output Block 30
✗ Output Block 31
✗ Output Block 32
✗ Output Block 33
✗ Output Block 34
✗ Output Block 35
✓ Output Block 36
✓ Output Block 37
✓ Output Block 38
✓ Output Block 39
✓ Output Block 40
✓ Output Block 41
✓ Output Block 42
✓ Output Block 43
✓ Output Block 44
✗ Output Block 45
✓ Output Block 46
✓ Output Block 47
✗ Output Block 48
✓ Output Block 49
✓ Output Block 50
✗ FAIL: Output Block 51
✓ Output Block 52
✗ Output Block 53
✗ FAIL: Output Block 54
✓ Output Block 55
✓ Output Block 56
✗ Output Block 57
✗ Output Block 58
✓ Output Block 59
✓ Output Block 60
✗ Output Block 61
✗ Output Block 62
✓ Output Block 63
✓ Output Block 64
✓ Output Block 65
✓ Output Block 66
✓ Output Block 67
✓ Output Block 68
✓ Output Block 69
✓ Output Block 70
✓ Output Block 71
✓ Output Block 72
✓ Output Block 73
✓ Output Block 74
✓ Output Block 75
✓ Output Block 76
✓ Output Block 77
✓ Output Block 78
✓ Output Block 79
✓ Output Block 80
✓ Output Block 81
✓ Output Block 82
✗ Output Block 83
✓ Output Block 84
✓ Output Block 85
✗ Output Block 86
✓ Output Block 87
✗ Output Block 88
✓ Output Block 89
✓ Output Block 90
✓ Output Block 91
✓ Output Block 92
✗ Output Block 93
✗ Output Block 94
✓ Output Block 95
✓ Output Block 96
✓ Output Block 97
✓ Output Block 98
✗ Output Block 99
✓ Output Block 100
✓ Output Block 101
✓ Output Block 102
✓ Output Block 103
✗ Output Block 104
✓ Output Block 105
✓ Output Block 106
✗ Output Block 107
✓ Output Block 108
✓ Output Block 109
✗ Output Block 110
✓ Output Block 111
✗ Output Block 112
✓ Output Block 113
✗ Output Block 114
✓ Output Block 115
✗ Output Block 116
✓ Output Block 117
✓ Output Block 118
✓ Output Block 119
✓ Output Block 120
✓ Output Block 121
✓ Output Block 122
✗ Output Block 123
✓ Output Block 124
✓ Output Block 125
✓ Output Block 126
✓ Output Block 127
✓ Output Block 128
✓ Output Block 129
✓ Output Block 130
✗ Output Block 131
✗ Output Block 132
✓ Output Block 133
✓ Output Block 134
✓ Output Block 135
✓ Output Block 136
✓ Output Block 137
✓ Output Block 138
✓ Output Block 139
✓ Output Block 140
✗ Output Block 141
✗ Output Block 142
✗ Output Block 143
✗ Output Block 144
✓ Output Block 145
✓ Output Block 146
✓ Output Block 147
✓ Output Block 148
✓ Output Block 149
✓ Output Block 150
✓ Output Block 151
✗ Output Block 152
✗ Output Block 153
✗ nox > Running session unit_tests-3.13
✗ Error Output
--- Failed Tests ---
✗ Output Block 11
1 feature passed, 0 failed, 0 skipped
10 scenarios passed, 0 failed, 0 skipped
23 steps passed, 0 failed, 0 skipped
Took 0min 0.006s
Feature: Devcontainer Lifecycle State Model
As a CleverAgents developer
I want the container lifecycle state model to enforce valid transitions
So that containers follow a predictable state machine
Scenario: ContainerLifecycleState has all required values
When I inspect ContainerLifecycleState enum values
Then the lifecycle enum should contain "discovered"
And the lifecycle enum should contain "building"
And the lifecycle enum should contain "running"
And the lifecycle enum should contain "stopping"
And the lifecycle enum should contain "stopped"
And the lifecycle enum should contain "failed"
✗ Output Block 14
Scenario: Invalid transition is rejected
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000003"
When I attempt to transition from "discovered" to "running"
Then the transition should raise ValueError
✗ Output Block 15
Scenario: Stopping to error transition is valid
Given a lifecycle tracker in "stopping" state for resource "01TESTLIFECYCLE0000000004"
When I transition from "stopping" to "failed"
Then the transition should succeed
And the tracker state should be "failed"
✗ Output Block 16
Scenario: Error to starting transition is valid for retry
Given a lifecycle tracker in "failed" state for resource "01TESTLIFECYCLE0000000005"
When I transition from "failed" to "building"
Then the transition should succeed
And the tracker state should be "building"
✗ Output Block 23
Scenario: validate_transition rejects wrong types
When I call validate_transition with non-enum arguments
Then it should raise TypeError
✗ Output Block 24
Scenario: validate_transition rejects wrong to_state type
When I call validate_transition with non-enum to_state
Then it should raise TypeError
✗ Output Block 25
Scenario: transition_state rejects wrong tracker type
When I call transition_state with non-tracker argument
Then it should raise TypeError
✗ Output Block 26
Scenario: transition_state rejects wrong to_state type
When I call transition_state with non-enum to_state argument
Then it should raise TypeError
Feature: agents resource list shows devcontainer lifecycle state column
As a CleverAgents user
I want the resource list to show the lifecycle state for devcontainer-instance resources
So that I can see whether a devcontainer is discovered, running, stopped, or failed
Feature: agents resource list shows devcontainer lifecycle state column
✗ Output Block 28
Scenario: get_lifecycle_tracker rejects empty resource_id
When I attempt to get tracker with empty resource_id
Then the get tracker should raise ValueError
✗ Output Block 29
Scenario: set_lifecycle_tracker rejects wrong type
When I attempt to set tracker with wrong type
Then the set tracker should raise TypeError
✗ Output Block 30
Scenario: Invalid transition running to building is rejected
Given a lifecycle tracker in "running" state for resource "01TESTLIFECYCLE0000000240"
When I attempt to transition from "running" to "building"
Then the transition should raise ValueError
✗ Output Block 31
Scenario: Invalid transition stopped to running is rejected
Given a lifecycle tracker in "stopped" state for resource "01TESTLIFECYCLE0000000241"
When I attempt to transition from "stopped" to "running"
Then the transition should raise ValueError
✗ Output Block 32
Scenario: Invalid transition building to stopped is rejected
Given a lifecycle tracker in "building" state for resource "01TESTLIFECYCLE0000000242"
When I attempt to transition from "building" to "stopped"
Then the transition should raise ValueError
✗ Output Block 33
Scenario: Invalid transition failed to running is rejected
Given a lifecycle tracker in "failed" state for resource "01TESTLIFECYCLE0000000243"
When I attempt to transition from "failed" to "running"
Then the transition should raise ValueError
✗ Output Block 34
Scenario: Invalid transition discovered to stopping is rejected
Given a lifecycle tracker for resource "01TESTLIFECYCLE0000000244"
When I attempt to transition from "discovered" to "stopping"
Then the transition should raise ValueError
✗ Output Block 35
Scenario: Invalid transition building to discovered is rejected
Given a lifecycle tracker in "building" state for resource "01TESTLIFECYCLE0000000245"
When I attempt to transition from "building" to "discovered"
Then the transition should raise ValueError
✗ Output Block 45
1 feature passed, 0 failed, 0 skipped
32 scenarios passed, 0 failed, 0 skipped
106 steps passed, 0 failed, 0 skipped
Took 0min 0.037s
And built-in types are bootstrapped
Given a devcontainer-instance resource is registered
When I run resource list with all flag
Then the resource output should contain "discovered (not built)"
✗ Output Block 48
Scenario: resource list shows "failed" state for a failed devcontainer
Given a fresh in-memory resource registry
And built-in types are bootstrapped
Given a devcontainer-instance resource is registered
And the devcontainer lifecycle state is "failed"
When I run resource list with all flag
Then the resource output should contain "failed"
✗ FAIL: Output Block 51
ASSERT FAILED: Expected 'Devcontainer detected' in output, got:
Resources (1 total)
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓
┃ ID ┃ Name ┃ Type ┃ Status ┃ Kind ┃ Location ┃ Description ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩
│ 01KP2TDV4GAR... │ (unnamed) │ devcontainer-instance │ discovered (not built) │ physical │ /tmp/test-project │ Test devcontainer │
└─────────────────┴───────────┴───────────────────────┴────────────────────────┴──────────┴───────────────────┴───────────────────┘
✗ Output Block 53
@tdd_issue @tdd_issue_4263 @tdd_expected_fail
Scenario: resource list JSON output includes lifecycle_state for devcontainer-instance
Given a fresh in-memory resource registry
And built-in types are bootstrapped
Given a devcontainer-instance resource is registered
When I run resource list with all flag and format "json"
Then the resource output should be valid JSON
And the resource JSON output should contain key "lifecycle_state"
✗ FAIL: Output Block 54
ASSERT FAILED: Key 'lifecycle_state' not found in JSON output
✗ Output Block 57
Scenario: resource list succeeds gracefully when lifecycle tracker raises an error
Given a fresh in-memory resource registry
And built-in types are bootstrapped
Feature: Devcontainer Resource Handler
As a CleverAgents developer
I want devcontainer resources to be auto-discovered and manually registered
So that devcontainer environments integrate into the resource model Given a devcontainer-instance resource is registered
✗ Output Block 58
Scenario: Register devcontainer-instance manually
Given a temporary directory with a valid devcontainer.json
When I create a devcontainer-instance resource at that path
Then And the resource should have type "the lifecycle tracker raises an error for the resourcedevcontainer-instance"
✗ Output Block 61
Failing scenarios:
features/resource_list_lifecycle_state.feature:44 resource list shows warning banner when devcontainer is in detected state
✗ Output Block 62
0 features passed, 1 failed, 0 skipped
11 scenarios passed, 1 failed, 0 skipped
69 steps passed, 1 failed, 0 skipped
Took 0min 1.827s
✗ Output Block 83
Scenario Outline: validate_clone_into_url rejects invalid URLs -- @1.1
When I validate clone-into URL "not-a-url"
Then it should raise a ValueError
Then the clone-into URL should be invalid
✗ Output Block 86
Scenario: clone_repo_into_container rejects empty container_id
When I call clone_repo_into_container with empty container_id
Then it should raise ValueError mentioning "container_id"
✗ Output Block 88
Scenario: clone_repo_into_container rejects empty repo_url
When I call clone_repo_into_container with empty repo_url
And the directory has a named devcontainer configuration "frontend"
Then it should raise ValueError mentioning "repo_url"
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 2 results
And the result config names should include "api"
✗ Output Block 93
Scenario: clone_repo_into_container raises CloneIntoError when docker exec fails
Given a mock docker exec that fails for clone with stderr "fatal: repository not found"
When I clone "https://github.com/org/missing.git" into container "abc123def456"
When I run devcontainer discovery on the directory as "git-checkout"
Then the clone should raise CloneIntoError
And the CloneIntoError should mention "repository not found"
Then discovery should return 1 result
And the first result should have no config name
✗ Output Block 94
Scenario: CloneIntoError stores repo_url, container_id, and stderr
Given a CloneIntoError with repo "https://example.com/repo.git" container "ctr-001" stderr "auth failed"
Then the CloneIntoError repo_url should be "https://example.com/repo.git"
And the CloneIntoError container_id should be "ctr-001"
And the CloneIntoError stderr should be "auth failed"
✗ Output Block 99
1 feature passed, 0 failed, 0 skipped
17 scenarios passed, 0 failed, 0 skipped
44 steps passed, 0 failed, 0 skipped
Took 0min 0.008s
Then discovery should return 0 results
✗ Output Block 104
1 feature passed, 0 failed, 0 skipped
26 scenarios passed, 0 failed, 0 skipped
90 steps passed, 0 failed, 0 skipped
Took 0min 0.026s
Feature: Devcontainer Session Cleanup and CLI Commands
As a CleverAgents developer
I want session-scoped container cleanup and CLI stop/rebuild commands
So that containers are cleaned up when sessions end and can be managed via CLI
Scenario: Active containers stopped on session cleanup
Given a mock devcontainer CLI runner
And an active container "01TESTLIFECYCLE0000000040" with container ID "ctr-a"
And an active container "01TESTLIFECYCLE0000000041" with container ID "ctr-b"
When I run session cleanup for session "session-001"
Then the container state for "01TESTLIFECYCLE0000000040" should be "stopped"
And the container state for "01TESTLIFECYCLE0000000041" should be "stopped"
✗ Output Block 107
Scenario: Session cleanup handles stop failure gracefully
Given a mock devcontainer CLI runner
And the runner configured to raise exception on stop
And an active container "01TESTLIFECYCLE0000000130" with container ID "ctr-fail"
When I run session cleanup for session "session-err"
Then the cleanup should return 0 stopped containers
✗ Output Block 110
Scenario: dcproto delete fails when container is stopped
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with location "/ws/project"
And dcproto subprocess returns failed rm output with stderr "container not running"
When dcproto I delete path "file.txt" from the resource
Then the CLI exit code should be 0
And the CLI output should contain "Stopped"
Then dcproto the delete should fail
And dcproto the delete failure message should contain "container not running"
✗ Output Block 112
Scenario: dcproto delete from resource with no location raises ValueError
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with no location
When dcproto I delete path "any/file.txt" from the resource
Then dcproto the delete should raise ValueError containing "no location"
✗ Output Block 114
Scenario: dcproto list_children returns empty list when container is stopped
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with location "/ws/project"
And dcproto subprocess returns failed ls output
When dcproto I list children of the resource
Then dcproto the children list should have 0 entries
✗ Output Block 116
Scenario: dcproto list_children from resource with no location raises ValueError
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with no location
When dcproto I list children of the resource
Then dcproto the list children should raise ValueError containing "no location"
✗ Output Block 123
Scenario: dcproto diff from resource with no location raises ValueError
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with no location
When dcproto I diff the resource against "/some/path"
Then the devcontainer CLI exit code should be non-zero
Then dcproto the diff should raise ValueError containing "no location"
And the CLI output should contain "no location"
✗ Output Block 131
Scenario: dcproto create_sandbox raises ValueError for resource with no location
Given dcproto a devcontainer handler
And dcproto a devcontainer-instance resource with no location
And dcproto a mock sandbox manager that returns a valid sandbox
When dcproto I create a sandbox for the resource with plan "plan-004"
Then dcproto the create_sandbox should raise ValueError containing "no location"
✗ Output Block 132
1 feature passed, 0 failed, 0 skipped
18 scenarios passed, 0 failed, 0 skipped
102 steps passed, 0 failed, 0 skipped
Took 0min 0.028s
Then the CLI exit code should be 0
And the CLI rebuild mock should have been called with "01TESTLIFECYCLE0000000221" and "/workspace/project"
✗ Output Block 141
Scenario: CLI stop handles NotFoundError from show_resource
Given a mock resource service that raises NotFoundError for "local/missing"
When I invoke CLI resource stop "local/missing"
Then the devcontainer CLI exit code should be non-zero
And the CLI output should contain "Resource not found"
✗ Output Block 142
Scenario: CLI rebuild handles NotFoundError from show_resource
Given a mock resource service that raises NotFoundError for "local/missing-rb"
When I invoke CLI resource rebuild "local/missing-rb"
Then the devcontainer CLI exit code should be non-zero
And the CLI output should contain "Resource not found"
✗ Output Block 143
Scenario: CLI stop handles RuntimeError from stop_container
Given a mock resource service with devcontainer "local/dc-rterr" id "01TESTLIFECYCLE0000000500" at "/workspace/project"
And an active container "01TESTLIFECYCLE0000000500" with container ID "ctr-rterr"
And CLI stop_container mock that raises RuntimeError
When I invoke CLI resource stop with error mock "local/dc-rterr"
Then the devcontainer CLI exit code should be non-zero
And the CLI output should contain "Stop failed"
✗ Output Block 144
Scenario: CLI rebuild handles RuntimeError from rebuild_container
Given a mock resource service with devcontainer "local/dc-rberr" id "01TESTLIFECYCLE0000000501" at "/workspace/project"
And a stopped container "01TESTLIFECYCLE0000000501"
And CLI rebuild_container mock that raises RuntimeError
When I invoke CLI resource rebuild with error mock "local/dc-rberr"
Then the devcontainer CLI exit code should be non-zero
And the CLI output should contain "Rebuild failed"
✗ Output Block 152
1 feature passed, 0 failed, 0 skipped
30 scenarios passed, 0 failed, 0 skipped
143 steps passed, 0 failed, 0 skipped
Took 0min 0.436s
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
2026-04-13 07:00:42 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:42 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:42 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:42 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
Including unscoped container 01TESTLIFECYCLE0000000040 in session 'session-001' cleanup (container has no session_id set)
Including unscoped container 01TESTLIFECYCLE0000000041 in session 'session-001' cleanup (container has no session_id set)
Container 01TESTLIFECYCLE0000000040: running -> stopping (manual stop)
Container 01TESTLIFECYCLE0000000040: stopping -> stopped (container stopped)
Stopped container 01TESTLIFECYCLE0000000040 (session=session-001)
Container 01TESTLIFECYCLE0000000041: running -> stopping (manual stop)
Container 01TESTLIFECYCLE0000000041: stopping -> stopped (container stopped)
Stopped container 01TESTLIFECYCLE0000000041 (session=session-001)
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
Registered built-in resource type: fs-mount
Registered built-in resource type: git-checkout
Registered built-in resource type: fs-directory
Registered built-in resource type: container-instance
Registered built-in resource type: devcontainer-instance
Registered built-in resource type: devcontainer-file
Registered built-in resource type: git
Registered built-in resource type: git-remote
Registered built-in resource type: git-branch
Registered built-in resource type: git-tag
Registered built-in resource type: git-commit
Registered built-in resource type: git-tree
Registered built-in resource type: git-tree-entry
Registered built-in resource type: git-stash
Registered built-in resource type: git-submodule
Registered built-in resource type: fs-symlink
Registered built-in resource type: fs-hardlink
Registered built-in resource type: cloud-account
Registered built-in resource type: cloud-region
Registered built-in resource type: cloud-network
Registered built-in resource type: cloud-subnet
Registered built-in resource type: cloud-security-group
Registered built-in resource type: cloud-load-balancer
Registered built-in resource type: cloud-compute-instance
Registered built-in resource type: cloud-object-store
Registered built-in resource type: cloud-block-storage
Registered built-in resource type: cloud-identity-principal
Registered built-in resource type: cloud-role
Registered built-in resource type: cloud-policy
Registered built-in resource type: cloud-log-group
Registered built-in resource type: cloud-alarm
Registered built-in resource type: cloud-queue
Registered built-in resource type: cloud-topic
Registered built-in resource type: cloud-container-repo
Registered built-in resource type: cloud-container-cluster
Registered built-in resource type: cloud-container-service
Registered built-in resource type: aws-account
Registered built-in resource type: aws-region
Registered built-in resource type: aws-vpc
Registered built-in resource type: aws-subnet
Registered built-in resource type: aws-igw
Registered built-in resource type: aws-nat-gw
Registered built-in resource type: aws-route-table
Registered built-in resource type: aws-nacl
Registered built-in resource type: aws-security-group
Registered built-in resource type: aws-alb
Registered built-in resource type: aws-nlb
Registered built-in resource type: aws-target-group
Registered built-in resource type: aws-listener
Registered built-in resource type: aws-ec2-instance
Registered built-in resource type: aws-ami
Registered built-in resource type: aws-launch-template
Registered built-in resource type: aws-asg
Registered built-in resource type: aws-s3-bucket
Registered built-in resource type: aws-ebs-volume
Registered built-in resource type: aws-efs-filesystem
Registered built-in resource type: aws-iam-user
Registered built-in resource type: aws-iam-role
Registered built-in resource type: aws-iam-policy
Registered built-in resource type: aws-iam-instance-profile
Registered built-in resource type: aws-cloudwatch-log-group
Registered built-in resource type: aws-cloudwatch-alarm
Registered built-in resource type: aws-cloudwatch-metric
Registered built-in resource type: aws-eventbridge-bus
Registered built-in resource type: aws-eventbridge-rule
Registered built-in resource type: aws-eventbridge-target
Registered built-in resource type: aws-sqs-queue
Registered built-in resource type: aws-sns-topic
Registered built-in resource type: aws-sns-subscription
Registered built-in resource type: aws-ecr-repo
Registered built-in resource type: aws-ecs-cluster
Registered built-in resource type: aws-ecs-service
Registered built-in resource type: aws-ecs-task-def
Registered built-in resource type: aws-eks-cluster
Registered built-in resource type: aws-eks-nodegroup
Registered built-in resource type: gcp
Registered built-in resource type: azure
Registered built-in resource type: container-runtime
Registered built-in resource type: container-image
Registered built-in resource type: container-mount
Registered built-in resource type: container-exec-env
Registered built-in resource type: container-port
Registered built-in resource type: container-volume
Registered built-in resource type: container-network
Registered built-in resource type: postgres
Registered built-in resource type: mysql
Registered built-in resource type: sqlite
Registered built-in resource type: duckdb
Registered built-in resource type: file
Registered built-in resource type: directory
Registered built-in resource type: commit
Registered built-in resource type: branch
Registered built-in resource type: tag
Registered built-in resource type: tree
Registered built-in resource type: remote
Registered built-in resource type: submodule
Registered built-in resource type: symlink
Registered built-in resource type: executable
Registered built-in resource type: lsp-server
Registered built-in resource type: lsp-workspace
Registered built-in resource type: lsp-document
2026-04-13 07:00:43 [debug ] detail_level_map_builder.created child_domain=uko-oo: parent_domain=uko-code:
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=MEMBER_LISTING domain=uko-oo: new_level=CLASS_HIERARCHY
2026-04-13 07:00:43 [debug ] detail_level_map_builder.insert_after after_level=SIGNATURES_WITH_DOCS domain=uko-oo: new_level=VISIBILITY_ANNOTATED
2026-04-13 07:00:43 [debug ] detail_level_map_builder.built domain=uko-oo: max_depth=11 num_levels=12
✗ Output Block 153
Overall summary:
6 features passed, 1 failed, 0 errored, 0 skipped
144 scenarios passed, 1 failed, 0 errored, 0 skipped
553 steps passed, 1 failed, 0 errored, 0 skipped
Took 2.366s
Wall time: 2m 47.786s
✗ nox > Running session unit_tests-3.13
nox > Running session unit_tests-3.13
nox > Reusing existing virtual environment at .nox/unit_tests-3-13.
nox > uv pip install -e '.[tests]'
nox > uv pip install setuptools wheel
nox > uv pip install .nox/unit_tests-3-13/tmp/behave-parallel-inprocess
nox > python scripts/create_template_db.py /tmp/impl-worker-7555-20260413/build/.template-migrated.db
nox > python -m compileall -q features/
nox > /tmp/impl-worker-7555-20260413/.nox/unit_tests-3-13/bin/behave-parallel -q --processes 32 features/container_clone_into.feature features/devcontainer_sandbox_strategy.feature features/devcontainer_state.feature features/devcontainer_handler.feature features/devcontainer_cleanup.feature features/resource_list_lifecycle_state.feature features/devcontainer_handler_protocol_methods.feature
nox > Command /tmp/impl-worker-7555-20260413/.nox/unit_tests-3-13/bin/behave-parallel -q --processes 32 features/container_clone_into.feature features/devcontainer_sandbox_strategy.feature features/devcontainer_state.feature features/devcontainer_handler.feature features/devcontainer_cleanup.feature features/resource_list_lifecycle_state.feature features/devcontainer_handler_protocol_methods.feature failed with exit code 1
nox > Session unit_tests-3.13 failed.
✗ Error Output
nox > Running session unit_tests-3.13
nox > Reusing existing virtual environment at .nox/unit_tests-3-13.
nox > uv pip install -e '.[tests]'
nox > uv pip install setuptools wheel
nox > uv pip install .nox/unit_tests-3-13/tmp/behave-parallel-inprocess
nox > python scripts/create_template_db.py /tmp/impl-worker-7555-20260413/build/.template-migrated.db
nox > python -m compileall -q features/
nox > /tmp/impl-worker-7555-20260413/.nox/unit_tests-3-13/bin/behave-parallel -q --processes 32 features/container_clone_into.feature features/devcontainer_sandbox_strategy.feature features/devcontainer_state.feature features/devcontainer_handler.feature features/devcontainer_cleanup.feature features/resource_list_lifecycle_state.feature features/devcontainer_handler_protocol_methods.feature
nox > Command /tmp/impl-worker-7555-20260413/.nox/unit_tests-3-13/bin/behave-parallel -q --processes 32 features/container_clone_into.feature features/devcontainer_sandbox_strategy.feature features/devcontainer_state.feature features/devcontainer_handler.feature features/devcontainer_cleanup.feature features/resource_list_lifecycle_state.feature features/devcontainer_handler_protocol_methods.feature failed with exit code 1
nox > Session unit_tests-3.13 failed.
File diff suppressed because one or more lines are too long