7ac3f1352c
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 26s
CI / security (pull_request) Successful in 37s
CI / typecheck (pull_request) Successful in 57s
CI / unit_tests (pull_request) Successful in 3m14s
CI / integration_tests (pull_request) Successful in 3m33s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 5m29s
CI / lint (push) Successful in 16s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 16s
CI / security (push) Successful in 35s
CI / typecheck (push) Successful in 47s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m1s
CI / integration_tests (push) Successful in 3m31s
CI / docker (push) Successful in 52s
CI / coverage (push) Successful in 5m47s
CI / benchmark-publish (push) Successful in 19m3s
CI / benchmark-regression (pull_request) Successful in 35m19s
Implement ContainerToolExecutor for delegating tool invocations to devcontainer environments with full I/O forwarding. Add PathMapper for bidirectional host/container path translation. Wire container routing into ToolRunner with graceful fallback when no executor is configured. Add container_metadata field to ToolInvocation for tracking execution context. New modules: - tool/container_executor.py: ContainerToolExecutor, ContainerConfig, ContainerMetadata, ContainerExecutionError, ContainerTimeoutError - tool/path_mapper.py: PathMapper with host_to_container/container_to_host Modified: - tool/runner.py: container execution routing via ExecutionEnvironment - domain/models/core/change.py: container_metadata on ToolInvocation - tool/__init__.py: new public exports Review fixes applied: - Add Alembic migration m6_004 for container_metadata_json column - Enforce _MAX_OUTPUT_BYTES (50 MiB) truncation in _run_command() - Fix path traversal in sync_results_to_host (Path.is_relative_to) - Allow spaces in _looks_like_path() for valid filesystem paths - Preserve negative exit codes from signal kills in metadata - Add default=str to json.dumps(invocation.arguments) safety net - Log warnings when path mapping recursion depth exceeded - Warn when devcontainer binary not found on PATH - Use default allow_nan for host-path JSON validation in runner.py (only container path requires RFC 7159 strict mode) - Reject URL-like patterns in _looks_like_path() to avoid false positives on API routes, protocol-relative URIs, and query strings - Add extract_container_metadata() static helper on ContainerToolExecutor as bridge for ToolInvocation wiring - Use raw_stdout bytes in sync_results_to_host to prevent binary file corruption from text-mode decode/re-encode - Apply posixpath.normpath() in workspace_folder validator and reject path components containing '..' - Check result.timed_out in sync_results_to_host and raise ContainerTimeoutError instead of always raising ContainerExecutionError - Detect overlapping host_root/container_root in PathMapper and raise ValueError to prevent corrupt bidirectional mappings - Wrap host-side I/O in sync_results_to_host with try/except OSError to produce ContainerExecutionError on write failure - Enforce int(timeout) in _build_exec_command to prevent shell injection via malicious objects with __str__ methods - Change ToolResult validator from 'not self.error' to 'self.error is None' so empty-string errors are accepted - Iterate required list directly in ToolRunner schema validation to detect fields listed in required but absent from properties Closes #515
259 lines
9.2 KiB
Python
259 lines
9.2 KiB
Python
"""Step definitions for devcontainer_handler.feature.
|
|
|
|
Tests devcontainer resource registration, auto-discovery, handler
|
|
protocol conformance, and resource type registry integration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
|
|
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
|
|
from cleveragents.resource.handlers.discovery import (
|
|
DevcontainerDiscoveryResult,
|
|
discover_devcontainers,
|
|
is_trigger_type,
|
|
)
|
|
from cleveragents.resource.handlers.protocol import ResourceHandler
|
|
|
|
_VALID_DC_JSON: str = json.dumps(
|
|
{"name": "test-devcontainer", "image": "ubuntu:latest"}
|
|
)
|
|
|
|
|
|
# ── Given steps ──────────────────────────────────────────────
|
|
|
|
|
|
@given("a temporary directory with a valid devcontainer.json")
|
|
def step_tmp_dir_valid_dc(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
tmp = Path(context.tmp_dir_obj.name)
|
|
dc_dir = tmp / ".devcontainer"
|
|
dc_dir.mkdir()
|
|
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
|
|
context.tmp_path = str(tmp)
|
|
|
|
|
|
@given("a temporary directory simulating a git checkout")
|
|
def step_tmp_dir_git(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
context.tmp_path = context.tmp_dir_obj.name
|
|
|
|
|
|
@given("a temporary directory simulating a filesystem mount")
|
|
def step_tmp_dir_fs(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
context.tmp_path = context.tmp_dir_obj.name
|
|
|
|
|
|
@given("the directory has a .devcontainer/devcontainer.json file")
|
|
def step_add_dc_subdir(context: Context) -> None:
|
|
tmp = Path(context.tmp_path)
|
|
dc_dir = tmp / ".devcontainer"
|
|
dc_dir.mkdir(exist_ok=True)
|
|
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
|
|
|
|
|
|
@given("the directory has a root .devcontainer.json file")
|
|
def step_add_dc_root(context: Context) -> None:
|
|
tmp = Path(context.tmp_path)
|
|
(tmp / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
|
|
|
|
|
|
@given("a temporary directory with an invalid devcontainer.json")
|
|
def step_tmp_dir_invalid_dc(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
tmp = Path(context.tmp_dir_obj.name)
|
|
dc_dir = tmp / ".devcontainer"
|
|
dc_dir.mkdir()
|
|
(dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
|
|
context.tmp_path = str(tmp)
|
|
|
|
|
|
@given("a temporary directory with a non-object devcontainer.json")
|
|
def step_tmp_dir_nonobj_dc(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
tmp = Path(context.tmp_dir_obj.name)
|
|
dc_dir = tmp / ".devcontainer"
|
|
dc_dir.mkdir()
|
|
(dc_dir / "devcontainer.json").write_text(
|
|
'["not", "an", "object"]', encoding="utf-8"
|
|
)
|
|
context.tmp_path = str(tmp)
|
|
|
|
|
|
@given("a temporary directory with no devcontainer configuration")
|
|
def step_tmp_dir_no_dc(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
context.tmp_path = context.tmp_dir_obj.name
|
|
|
|
|
|
@given("the built-in resource type registry")
|
|
def step_builtin_registry(context: Context) -> None:
|
|
context.builtin_names = ResourceTypeSpec.BUILTIN_NAMES
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────
|
|
|
|
|
|
@when("I create a devcontainer-instance resource at that path")
|
|
def step_create_dc_resource(context: Context) -> None:
|
|
# Verify the type is actually recognised by the built-in registry
|
|
# instead of just assigning a string and asserting it back (U7).
|
|
context.resource_type = "devcontainer-instance"
|
|
assert context.resource_type in ResourceTypeSpec.BUILTIN_NAMES, (
|
|
f"'{context.resource_type}' is not a built-in resource type"
|
|
)
|
|
# Verify discovery finds the devcontainer in our temp directory
|
|
results = discover_devcontainers(
|
|
resource_location=context.tmp_path,
|
|
resource_type="git-checkout",
|
|
)
|
|
assert len(results) > 0, "Expected at least one discovery result"
|
|
context.resource_id = f"01TEST{results[0].config_path.name}"
|
|
context.resource_created = True
|
|
|
|
|
|
@when('I create a container-instance resource with image "{image}"')
|
|
def step_create_ctr_resource(context: Context, image: str) -> None:
|
|
# Verify the type is actually recognised by the built-in registry
|
|
# instead of just assigning a string and asserting it back (U7).
|
|
context.container_type = "container-instance"
|
|
assert context.container_type in ResourceTypeSpec.BUILTIN_NAMES, (
|
|
f"'{context.container_type}' is not a built-in resource type"
|
|
)
|
|
context.container_image = image
|
|
context.container_created = True
|
|
|
|
|
|
@when('I run devcontainer discovery on the directory as "{rtype}"')
|
|
def step_run_discovery(context: Context, rtype: str) -> None:
|
|
context.discovery_results = discover_devcontainers(
|
|
resource_location=context.tmp_path,
|
|
resource_type=rtype,
|
|
)
|
|
|
|
|
|
@when("I list all built-in type names")
|
|
def step_list_builtin_names(context: Context) -> None:
|
|
context.type_name_list = sorted(context.builtin_names)
|
|
|
|
|
|
@when("I instantiate DevcontainerHandler")
|
|
def step_instantiate_handler(context: Context) -> None:
|
|
context.handler = DevcontainerHandler()
|
|
|
|
|
|
@when("I create a discovery result with valid parameters")
|
|
def step_create_valid_result(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
tmp = Path(context.tmp_dir_obj.name)
|
|
dc_file = tmp / "devcontainer.json"
|
|
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
|
|
context.discovery_result = DevcontainerDiscoveryResult(
|
|
config_path=dc_file,
|
|
config_data=json.loads(_VALID_DC_JSON),
|
|
parent_location=str(tmp),
|
|
)
|
|
|
|
|
|
@when("I create a discovery result with empty parent_location")
|
|
def step_create_invalid_result(context: Context) -> None:
|
|
context.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
tmp = Path(context.tmp_dir_obj.name)
|
|
dc_file = tmp / "devcontainer.json"
|
|
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
|
|
context.result_error = None
|
|
try:
|
|
DevcontainerDiscoveryResult(
|
|
config_path=dc_file,
|
|
config_data=json.loads(_VALID_DC_JSON),
|
|
parent_location="",
|
|
)
|
|
except ValueError as exc:
|
|
context.result_error = exc
|
|
|
|
|
|
# ── Then steps ───────────────────────────────────────────────
|
|
|
|
|
|
@then('the resource should have type "{expected_type}"')
|
|
def step_check_resource_type(context: Context, expected_type: str) -> None:
|
|
assert context.resource_type == expected_type
|
|
|
|
|
|
@then("the resource should have a non-empty resource_id")
|
|
def step_check_resource_id(context: Context) -> None:
|
|
assert context.resource_id
|
|
assert len(context.resource_id) > 0
|
|
|
|
|
|
@then('the container resource should have type "{expected_type}"')
|
|
def step_check_container_type(context: Context, expected_type: str) -> None:
|
|
assert context.container_type == expected_type
|
|
|
|
|
|
@then("discovery should return {count:d} result")
|
|
def step_check_discovery_count_singular(context: Context, count: int) -> None:
|
|
assert len(context.discovery_results) == count
|
|
|
|
|
|
@then("discovery should return {count:d} results")
|
|
def step_check_discovery_count_plural(context: Context, count: int) -> None:
|
|
assert len(context.discovery_results) == count
|
|
|
|
|
|
@then('the discovered config path should end with "{suffix}"')
|
|
def step_check_config_path_suffix(context: Context, suffix: str) -> None:
|
|
assert len(context.discovery_results) > 0
|
|
result = context.discovery_results[0]
|
|
assert str(result.config_path).endswith(suffix)
|
|
|
|
|
|
@then('the type list should contain "{type_name}"')
|
|
def step_check_type_in_list(context: Context, type_name: str) -> None:
|
|
assert type_name in context.builtin_names
|
|
|
|
|
|
@then("it should satisfy the ResourceHandler protocol")
|
|
def step_check_protocol(context: Context) -> None:
|
|
assert isinstance(context.handler, ResourceHandler)
|
|
|
|
|
|
@then('its default strategy should be "{strategy}"')
|
|
def step_check_default_strategy(context: Context, strategy: str) -> None:
|
|
assert context.handler._default_strategy.value == strategy
|
|
|
|
|
|
@then('its type label should be "{label}"')
|
|
def step_check_type_label(context: Context, label: str) -> None:
|
|
assert context.handler._type_label == label
|
|
|
|
|
|
@then("the result should have a config_path attribute")
|
|
def step_check_result_attr(context: Context) -> None:
|
|
assert hasattr(context.discovery_result, "config_path")
|
|
assert context.discovery_result.config_path is not None
|
|
|
|
|
|
@then("it should raise a ValueError")
|
|
def step_check_value_error(context: Context) -> None:
|
|
assert context.result_error is not None
|
|
assert isinstance(context.result_error, ValueError)
|
|
|
|
|
|
@then('"{rtype}" should be a trigger type')
|
|
def step_check_is_trigger(context: Context, rtype: str) -> None:
|
|
assert is_trigger_type(rtype) is True
|
|
|
|
|
|
@then('"{rtype}" should not be a trigger type')
|
|
def step_check_not_trigger(context: Context, rtype: str) -> None:
|
|
assert is_trigger_type(rtype) is False
|