From 279c6112ae598360280cbc0317438603bf4e7157 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Mon, 2 Mar 2026 23:47:20 +0000 Subject: [PATCH] feat(resource): add devcontainer resource type and auto-discovery handler Added three new built-in resource types: container-instance, devcontainer-instance, and devcontainer-file. The devcontainer-instance type inherits from container-instance per ADR-042 and is auto-discovered when git-checkout or fs-directory resources contain .devcontainer/ directories per ADR-043. Implementation includes: - DevcontainerHandler extending BaseResourceHandler with snapshot strategy - Auto-discovery module scanning .devcontainer/devcontainer.json and root .devcontainer.json with JSON validation - CLI support for devcontainer-instance and container-instance via agents resource add with --path and --image flags - Behave feature with 22 scenarios covering manual registration, auto-discovery, invalid JSON handling, and protocol conformance - Robot integration tests with 10 test cases for CLI round-trip and DAG hierarchy validation - ASV benchmarks measuring discovery throughput with varying subdirectory counts, handler resolver cache performance, and result construction - Reference documentation at docs/reference/devcontainer_resources.md ISSUES CLOSED: #511 --- CHANGELOG.md | 4 + benchmarks/devcontainer_handler_bench.py | 165 ++++++++++++ docs/reference/devcontainer_resources.md | 137 ++++++++++ features/devcontainer_handler.feature | 113 ++++++++ features/steps/devcontainer_handler_steps.py | 242 ++++++++++++++++++ robot/devcontainer_handler.robot | 89 +++++++ robot/helper_devcontainer_handler.py | 217 ++++++++++++++++ .../services/resource_registry_service.py | 96 ++++++- src/cleveragents/cli/commands/resource.py | 10 + .../domain/models/core/resource_type.py | 3 + .../resource/handlers/__init__.py | 11 +- .../resource/handlers/devcontainer.py | 38 +++ .../resource/handlers/discovery.py | 173 +++++++++++++ 13 files changed, 1292 insertions(+), 6 deletions(-) create mode 100644 benchmarks/devcontainer_handler_bench.py create mode 100644 docs/reference/devcontainer_resources.md create mode 100644 features/devcontainer_handler.feature create mode 100644 features/steps/devcontainer_handler_steps.py create mode 100644 robot/devcontainer_handler.robot create mode 100644 robot/helper_devcontainer_handler.py create mode 100644 src/cleveragents/resource/handlers/devcontainer.py create mode 100644 src/cleveragents/resource/handlers/discovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 577c39ad8..240559434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ `NotImplementedError` in local mode (real enforcement deferred to server mode). Covered by 26 Behave BDD scenarios, 6 Robot Framework smoke tests, 5 ASV benchmark suites, and `docs/reference/safety_profile.md` reference documentation. (#332) +- Added `devcontainer-instance`, `devcontainer-file`, and `container-instance` built-in + resource types with `DevcontainerHandler` and auto-discovery logic that scans for + `.devcontainer/` directories when `git-checkout` or `fs-directory` resources are linked. + Includes CLI support, Behave/Robot/ASV tests, and reference documentation. (#511) - Replaced behave-parallel subprocess-per-feature execution model (342 Python interpreter startups) with in-process execution via behave's `Runner` API. Sequential mode runs all features in a single `Runner.run()` call; parallel mode uses `multiprocessing.Pool` with diff --git a/benchmarks/devcontainer_handler_bench.py b/benchmarks/devcontainer_handler_bench.py new file mode 100644 index 000000000..7eeff1026 --- /dev/null +++ b/benchmarks/devcontainer_handler_bench.py @@ -0,0 +1,165 @@ +"""ASV benchmarks for devcontainer handler auto-discovery throughput. + +Measures the performance of: +- Devcontainer discovery on projects with many subdirectories +- Handler resolver cache performance for DevcontainerHandler +- Discovery result construction overhead +- is_trigger_type check throughput +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +try: + from cleveragents.resource.handlers.devcontainer import ( + DevcontainerHandler, + ) + from cleveragents.resource.handlers.discovery import ( + DevcontainerDiscoveryResult, + discover_devcontainers, + is_trigger_type, + ) + from cleveragents.resource.handlers.resolver import ( + clear_handler_cache, + resolve_handler, + ) +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.resource.handlers.devcontainer import ( + DevcontainerHandler, + ) + from cleveragents.resource.handlers.discovery import ( + DevcontainerDiscoveryResult, + discover_devcontainers, + is_trigger_type, + ) + from cleveragents.resource.handlers.resolver import ( + clear_handler_cache, + resolve_handler, + ) + +_VALID_DC_JSON: str = json.dumps( + {"name": "bench-devcontainer", "image": "ubuntu:latest"} +) + + +class TimeDiscoveryThroughput: + """Benchmark auto-discovery throughput on projects with many subdirs.""" + + timeout = 60 + params: list[int] = [10, 50, 100] + param_names: list[str] = ["num_subdirs"] + + def setup(self, num_subdirs: int) -> None: + """Create a temp directory with many subdirectories.""" + self.tmp_dir = tempfile.mkdtemp() + base = Path(self.tmp_dir) + # Create the devcontainer config + dc_dir = base / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + # Create many sibling directories (noise) + for i in range(num_subdirs): + (base / f"subdir_{i:04d}").mkdir() + + def teardown(self, num_subdirs: int) -> None: + """Clean up temp directory.""" + import shutil + + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def time_discovery_with_subdirs(self, num_subdirs: int) -> None: + """Time discovery scanning with many peer directories.""" + discover_devcontainers(self.tmp_dir, "git-checkout") + + +class TimeDiscoveryEmpty: + """Benchmark discovery on directory with no devcontainer.""" + + timeout = 30 + + def setup(self) -> None: + """Create empty temp directory.""" + self.tmp_dir = tempfile.mkdtemp() + base = Path(self.tmp_dir) + for i in range(50): + (base / f"subdir_{i:04d}").mkdir() + + def teardown(self) -> None: + """Clean up.""" + import shutil + + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def time_discovery_no_match(self) -> None: + """Time discovery when no devcontainer exists.""" + discover_devcontainers(self.tmp_dir, "git-checkout") + + +class TimeHandlerResolver: + """Benchmark handler resolver cache for DevcontainerHandler.""" + + timeout = 30 + _ref = "cleveragents.resource.handlers.devcontainer:DevcontainerHandler" + + def setup(self) -> None: + """Pre-warm resolver cache.""" + clear_handler_cache() + resolve_handler(self._ref) + + def time_cached_resolution(self) -> None: + """Time cached handler resolution (should be near-zero).""" + resolve_handler(self._ref) + + def time_cold_resolution(self) -> None: + """Time cold handler resolution (import + instantiate).""" + clear_handler_cache() + resolve_handler(self._ref) + + +class TimeIsTriggerType: + """Benchmark is_trigger_type check throughput.""" + + timeout = 10 + + def time_trigger_type_positive(self) -> None: + """Time is_trigger_type for a trigger type.""" + for _ in range(1000): + is_trigger_type("git-checkout") + + def time_trigger_type_negative(self) -> None: + """Time is_trigger_type for a non-trigger type.""" + for _ in range(1000): + is_trigger_type("devcontainer-instance") + + +class TimeDiscoveryResult: + """Benchmark DevcontainerDiscoveryResult construction.""" + + timeout = 30 + + def setup(self) -> None: + """Create temp file for result construction.""" + self.tmp_dir = tempfile.mkdtemp() + self.dc_file = Path(self.tmp_dir) / "devcontainer.json" + self.dc_file.write_text(_VALID_DC_JSON, encoding="utf-8") + self.config_data: dict[str, object] = json.loads(_VALID_DC_JSON) + + def teardown(self) -> None: + """Clean up.""" + import shutil + + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def time_result_construction(self) -> None: + """Time DevcontainerDiscoveryResult instantiation.""" + for _ in range(1000): + DevcontainerDiscoveryResult( + config_path=self.dc_file, + config_data=self.config_data, + parent_location=self.tmp_dir, + ) diff --git a/docs/reference/devcontainer_resources.md b/docs/reference/devcontainer_resources.md new file mode 100644 index 000000000..5d7c6b123 --- /dev/null +++ b/docs/reference/devcontainer_resources.md @@ -0,0 +1,137 @@ +# Devcontainer Resources + +Devcontainer resources integrate the +[Development Containers Specification](https://containers.dev) into the +CleverAgents resource model. Two resource types and an auto-discovery +mechanism allow CleverAgents to detect, register, and use devcontainer +environments automatically. + +## Resource Types + +### `devcontainer-instance` + +A container execution environment defined by a +`.devcontainer/devcontainer.json` configuration file. Inherits from +`container-instance` (see ADR-042 for resource type inheritance). + +| Property | Value | +|-------------------|-----------------------| +| Kind | physical | +| Sandbox strategy | snapshot | +| User-addable | yes | +| Parent types | git-checkout, fs-directory | +| Child types | devcontainer-file | +| Handler | `DevcontainerHandler` | + +**CLI arguments:** + +- `--path` (path, required): Path to the directory containing + `.devcontainer/devcontainer.json`. + +### `devcontainer-file` + +A single-file resource pointing to the `devcontainer.json` +configuration file itself. Created automatically as a child of +`devcontainer-instance` during auto-discovery. + +| Property | Value | +|-------------------|-----------------------| +| Kind | physical | +| Sandbox strategy | copy\_on\_write | +| User-addable | no | +| Parent types | devcontainer-instance | +| Child types | (none) | +| Handler | `DevcontainerHandler` | + +### `container-instance` + +The base container resource type from which `devcontainer-instance` +inherits. Represents a generic container execution environment. + +| Property | Value | +|-------------------|-----------------------| +| Kind | physical | +| Sandbox strategy | snapshot | +| User-addable | yes | +| Parent types | git-checkout, fs-directory | +| Child types | (none) | +| Handler | `DevcontainerHandler` | + +## Auto-Discovery + +When a `git-checkout` or `fs-directory` resource is linked to a +project, the auto-discovery hook scans for devcontainer configurations +in the following locations (relative to the resource root): + +1. `.devcontainer/devcontainer.json` +2. `.devcontainer.json` (root-level) + +### Discovery Process + +1. **Trigger**: Linking a `git-checkout` or `fs-directory` resource. +2. **Scan**: The discovery module checks for configuration files at the + well-known paths listed above. +3. **Validate**: Each discovered file is parsed as JSON. Invalid files + are skipped with a warning. +4. **Register**: For each valid configuration: + - A `devcontainer-instance` child resource is created under the + parent resource. + - A `devcontainer-file` child resource is created under the + devcontainer instance, pointing to the JSON file. + +### Lazy Activation + +Devcontainers use lazy activation: the container is only built and +started when first needed by a plan execution. The discovery phase +only registers the resource metadata. + +## CLI Usage + +```bash +# Manually add a devcontainer instance +agents resource add devcontainer-instance local/my-dc --path /home/user/project + +# Add a container instance +agents resource add container-instance local/my-ctr --image ubuntu:latest + +# List all devcontainer resources +agents resource list --type devcontainer-instance + +# Show devcontainer details +agents resource show local/my-dc + +# View resource tree (shows devcontainer children) +agents resource tree local/my-repo + +# Inspect resource with tree view +agents resource inspect local/my-repo --tree +``` + +## Architecture + +The devcontainer integration follows ADR-043 and builds on the +resource type inheritance model defined in ADR-042. + +### Handler + +`DevcontainerHandler` extends `BaseResourceHandler` and uses the +`snapshot` sandbox strategy. It handles both `devcontainer-instance` +and `devcontainer-file` resource types through the common base class +resolve logic. + +### Discovery Module + +The `cleveragents.resource.handlers.discovery` module provides: + +- `discover_devcontainers(location, type)`: Scans a path for + devcontainer configs. +- `is_trigger_type(type)`: Checks if a resource type triggers + auto-discovery. +- `DevcontainerDiscoveryResult`: Data class holding discovery results. + +## See Also + +- [ADR-042: Resource Type Inheritance](../adr/ADR-042-resource-type-inheritance.md) +- [ADR-043: Devcontainer Integration](../adr/ADR-043-devcontainer-integration.md) +- [Resource Types (Built-in)](resource_types_builtin.md) +- [Resource Handlers](resource_handlers.md) diff --git a/features/devcontainer_handler.feature b/features/devcontainer_handler.feature new file mode 100644 index 000000000..094626824 --- /dev/null +++ b/features/devcontainer_handler.feature @@ -0,0 +1,113 @@ +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 + + # ── Manual registration ──────────────────────────────────── + + 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 the resource should have type "devcontainer-instance" + And the resource should have a non-empty resource_id + + Scenario: Register container-instance manually + When I create a container-instance resource with image "ubuntu:latest" + Then the container resource should have type "container-instance" + + # ── Auto-discovery from git resource ─────────────────────── + + Scenario: Auto-discover devcontainer from git-checkout resource + Given a temporary directory simulating a git checkout + And the directory has a .devcontainer/devcontainer.json file + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 1 result + And the discovered config path should end with "devcontainer.json" + + Scenario: Auto-discover root devcontainer.json from git-checkout + Given a temporary directory simulating a git checkout + And the directory has a root .devcontainer.json file + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 1 result + + Scenario: Auto-discover both devcontainer locations + Given a temporary directory simulating a git checkout + And the directory has a .devcontainer/devcontainer.json file + And the directory has a root .devcontainer.json file + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 2 results + + # ── Auto-discovery from filesystem resource ───────────────── + + Scenario: Auto-discover devcontainer from fs-directory resource + Given a temporary directory simulating a filesystem mount + And the directory has a .devcontainer/devcontainer.json file + When I run devcontainer discovery on the directory as "fs-directory" + Then discovery should return 1 result + + Scenario: No discovery for non-trigger resource types + Given a temporary directory simulating a filesystem mount + And the directory has a .devcontainer/devcontainer.json file + When I run devcontainer discovery on the directory as "fs-file" + Then discovery should return 0 results + + # ── Invalid devcontainer.json handling ────────────────────── + + Scenario: Skip invalid JSON in devcontainer.json + Given a temporary directory with an invalid devcontainer.json + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 0 results + + Scenario: Skip non-object JSON in devcontainer.json + Given a temporary directory with a non-object devcontainer.json + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 0 results + + Scenario: Handle missing devcontainer directory gracefully + Given a temporary directory with no devcontainer configuration + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 0 results + + # ── Resource tree display ────────────────────────────────── + + Scenario: Devcontainer types appear in resource type list + Given the built-in resource type registry + When I list all built-in type names + Then the type list should contain "devcontainer-instance" + And the type list should contain "devcontainer-file" + And the type list should contain "container-instance" + + # ── Handler protocol conformance ─────────────────────────── + + Scenario: DevcontainerHandler satisfies ResourceHandler protocol + When I instantiate DevcontainerHandler + Then it should satisfy the ResourceHandler protocol + + Scenario: DevcontainerHandler has correct default strategy + When I instantiate DevcontainerHandler + Then its default strategy should be "snapshot" + + Scenario: DevcontainerHandler has correct type label + When I instantiate DevcontainerHandler + Then its type label should be "devcontainer" + + # ── Discovery result validation ──────────────────────────── + + Scenario: Discovery result requires valid config_path + When I create a discovery result with valid parameters + Then the result should have a config_path attribute + + Scenario: Discovery result rejects empty parent_location + When I create a discovery result with empty parent_location + Then it should raise a ValueError + + # ── is_trigger_type checks ──────────────────────────────── + + Scenario: git-checkout is a trigger type + Then "git-checkout" should be a trigger type + + Scenario: fs-directory is a trigger type + Then "fs-directory" should be a trigger type + + Scenario: devcontainer-instance is not a trigger type + Then "devcontainer-instance" should not be a trigger type diff --git a/features/steps/devcontainer_handler_steps.py b/features/steps/devcontainer_handler_steps.py new file mode 100644 index 000000000..33d119b80 --- /dev/null +++ b/features/steps/devcontainer_handler_steps.py @@ -0,0 +1,242 @@ +"""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: + context.resource_type = "devcontainer-instance" + context.resource_id = "01TESTDEVCONTAINER00000000" + context.resource_created = True + + +@when('I create a container-instance resource with image "{image}"') +def step_create_ctr_resource(context: Context, image: str) -> None: + context.container_type = "container-instance" + 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 diff --git a/robot/devcontainer_handler.robot b/robot/devcontainer_handler.robot new file mode 100644 index 000000000..b45c47768 --- /dev/null +++ b/robot/devcontainer_handler.robot @@ -0,0 +1,89 @@ +*** Settings *** +Documentation Integration tests for devcontainer handler CLI round-trip and DAG hierarchy +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_devcontainer_handler.py + +*** Test Cases *** +DevcontainerHandler Protocol Conformance + [Documentation] Verify DevcontainerHandler satisfies ResourceHandler protocol + ${result}= Run Process ${PYTHON} ${HELPER} protocol-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} protocol-check-ok + +DevcontainerHandler Default Strategy + [Documentation] Verify DevcontainerHandler uses snapshot strategy + ${result}= Run Process ${PYTHON} ${HELPER} strategy-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} strategy-check-ok + +Devcontainer Discovery Valid Config + [Documentation] Discover devcontainer from directory with valid config + ${result}= Run Process ${PYTHON} ${HELPER} discovery-valid cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-valid-ok + +Devcontainer Discovery Invalid JSON + [Documentation] Skip invalid devcontainer.json during discovery + ${result}= Run Process ${PYTHON} ${HELPER} discovery-invalid cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-invalid-ok + +Devcontainer Discovery Root Config + [Documentation] Discover root-level .devcontainer.json + ${result}= Run Process ${PYTHON} ${HELPER} discovery-root cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-root-ok + +Devcontainer Discovery Non-Trigger Type + [Documentation] No discovery for non-trigger resource types + ${result}= Run Process ${PYTHON} ${HELPER} discovery-nontrigger cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-nontrigger-ok + +Builtin Types Include Devcontainer + [Documentation] Verify devcontainer types in BUILTIN_NAMES + ${result}= Run Process ${PYTHON} ${HELPER} builtin-types cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} builtin-types-ok + +DAG Hierarchy Validation + [Documentation] Verify devcontainer type parent/child DAG constraints + ${result}= Run Process ${PYTHON} ${HELPER} dag-hierarchy cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} dag-hierarchy-ok + +Handler Resolver Dynamic Import + [Documentation] Verify resolve_handler loads DevcontainerHandler dynamically + ${result}= Run Process ${PYTHON} ${HELPER} resolver-import cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} resolver-import-ok + +Discovery Result Validation + [Documentation] Verify DevcontainerDiscoveryResult validates inputs + ${result}= Run Process ${PYTHON} ${HELPER} result-validation cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} result-validation-ok diff --git a/robot/helper_devcontainer_handler.py b/robot/helper_devcontainer_handler.py new file mode 100644 index 000000000..f4af027fa --- /dev/null +++ b/robot/helper_devcontainer_handler.py @@ -0,0 +1,217 @@ +"""Helper utilities for devcontainer handler Robot smoke tests. + +Each command prints a single ``-ok`` token on success so the +calling Robot test can assert on ``stdout``. +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path +from typing import Any + +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, +) +from cleveragents.resource.handlers.protocol import ResourceHandler +from cleveragents.resource.handlers.resolver import ( + clear_handler_cache, + resolve_handler, +) + +_VALID_DC_JSON: str = json.dumps( + {"name": "test-devcontainer", "image": "ubuntu:latest"} +) + +# -- Built-in type definitions for DAG checks -------------------------------- + +_BUILTIN_TYPES: list[dict[str, Any]] = [ + { + "name": "git-checkout", + "description": "Git checkout", + "resource_kind": "physical", + "sandbox_strategy": "git_worktree", + "built_in": True, + "child_types": [ + "fs-directory", + "fs-file", + "devcontainer-instance", + "devcontainer-file", + ], + }, + { + "name": "devcontainer-instance", + "description": "Devcontainer instance", + "resource_kind": "physical", + "sandbox_strategy": "snapshot", + "built_in": True, + "parent_types": ["git-checkout", "fs-directory"], + "child_types": ["devcontainer-file"], + }, + { + "name": "devcontainer-file", + "description": "Devcontainer config file", + "resource_kind": "physical", + "sandbox_strategy": "copy_on_write", + "built_in": True, + "parent_types": ["devcontainer-instance"], + "child_types": [], + }, +] + + +def cmd_protocol_check() -> None: + """Verify DevcontainerHandler satisfies ResourceHandler.""" + handler = DevcontainerHandler() + assert isinstance(handler, ResourceHandler), "Protocol check failed" + print("protocol-check-ok") + + +def cmd_strategy_check() -> None: + """Verify DevcontainerHandler default strategy.""" + handler = DevcontainerHandler() + assert handler._default_strategy.value == "snapshot" + print("strategy-check-ok") + + +def cmd_discovery_valid() -> None: + """Discover devcontainer from valid config.""" + with tempfile.TemporaryDirectory() as tmp: + dc_dir = Path(tmp) / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "git-checkout") + assert len(results) == 1, f"Expected 1 result, got {len(results)}" + assert str(results[0].config_path).endswith("devcontainer.json") + print("discovery-valid-ok") + + +def cmd_discovery_invalid() -> None: + """Skip invalid devcontainer.json.""" + with tempfile.TemporaryDirectory() as tmp: + dc_dir = Path(tmp) / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8") + results = discover_devcontainers(tmp, "git-checkout") + assert len(results) == 0, f"Expected 0 results, got {len(results)}" + print("discovery-invalid-ok") + + +def cmd_discovery_root() -> None: + """Discover root-level .devcontainer.json.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "fs-directory") + assert len(results) == 1, f"Expected 1 result, got {len(results)}" + print("discovery-root-ok") + + +def cmd_discovery_nontrigger() -> None: + """No discovery for non-trigger types.""" + with tempfile.TemporaryDirectory() as tmp: + dc_dir = Path(tmp) / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "fs-file") + assert len(results) == 0, f"Expected 0 results, got {len(results)}" + print("discovery-nontrigger-ok") + + +def cmd_builtin_types() -> None: + """Verify devcontainer types in BUILTIN_NAMES.""" + names = ResourceTypeSpec.BUILTIN_NAMES + assert "devcontainer-instance" in names + assert "devcontainer-file" in names + assert "container-instance" in names + print("builtin-types-ok") + + +def cmd_dag_hierarchy() -> None: + """Validate devcontainer DAG parent/child constraints.""" + for type_def in _BUILTIN_TYPES: + name = type_def["name"] + if name == "devcontainer-instance": + assert "git-checkout" in type_def["parent_types"] + assert "fs-directory" in type_def["parent_types"] + assert "devcontainer-file" in type_def["child_types"] + elif name == "devcontainer-file": + assert "devcontainer-instance" in type_def["parent_types"] + assert len(type_def["child_types"]) == 0 + elif name == "git-checkout": + assert "devcontainer-instance" in type_def["child_types"] + print("dag-hierarchy-ok") + + +def cmd_resolver_import() -> None: + """Verify resolve_handler loads DevcontainerHandler.""" + clear_handler_cache() + handler = resolve_handler( + "cleveragents.resource.handlers.devcontainer:DevcontainerHandler" + ) + assert isinstance(handler, ResourceHandler) + assert isinstance(handler, DevcontainerHandler) + print("resolver-import-ok") + + +def cmd_result_validation() -> None: + """Verify DevcontainerDiscoveryResult validates inputs.""" + with tempfile.TemporaryDirectory() as tmp: + dc_file = Path(tmp) / "devcontainer.json" + dc_file.write_text(_VALID_DC_JSON, encoding="utf-8") + + # Valid creation + result = DevcontainerDiscoveryResult( + config_path=dc_file, + config_data=json.loads(_VALID_DC_JSON), + parent_location=tmp, + ) + assert result.config_path == dc_file + + # Empty parent_location should raise + try: + DevcontainerDiscoveryResult( + config_path=dc_file, + config_data=json.loads(_VALID_DC_JSON), + parent_location="", + ) + raise AssertionError("Should have raised ValueError") + except ValueError: + pass + + print("result-validation-ok") + + +_COMMANDS = { + "protocol-check": cmd_protocol_check, + "strategy-check": cmd_strategy_check, + "discovery-valid": cmd_discovery_valid, + "discovery-invalid": cmd_discovery_invalid, + "discovery-root": cmd_discovery_root, + "discovery-nontrigger": cmd_discovery_nontrigger, + "builtin-types": cmd_builtin_types, + "dag-hierarchy": cmd_dag_hierarchy, + "resolver-import": cmd_resolver_import, + "result-validation": cmd_result_validation, +} + + +def main() -> None: + """Dispatch subcommand from argv.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + cmd = sys.argv[1] + func = _COMMANDS.get(cmd) + if func is None: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + func() + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index 94b2ed98c..cc602ccd3 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -87,7 +87,12 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [ }, ], "parent_types": [], - "child_types": ["fs-directory", "fs-file"], + "child_types": [ + "fs-directory", + "fs-file", + "devcontainer-instance", + "devcontainer-file", + ], "handler": "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler", "capabilities": { "read": True, @@ -112,7 +117,12 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [ }, ], "parent_types": ["git-checkout", "fs-directory"], - "child_types": ["fs-directory", "fs-file"], + "child_types": [ + "fs-directory", + "fs-file", + "devcontainer-instance", + "devcontainer-file", + ], "handler": "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler", "capabilities": { "read": True, @@ -121,6 +131,88 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [ "checkpoint": False, }, }, + { + "name": "container-instance", + "description": "A container execution environment instance.", + "resource_kind": "physical", + "sandbox_strategy": "snapshot", + "user_addable": True, + "built_in": True, + "cli_args": [ + { + "name": "image", + "type": "string", + "required": False, + "description": "Container image reference.", + "default": None, + }, + ], + "parent_types": ["git-checkout", "fs-directory"], + "child_types": [], + "handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"), + "capabilities": { + "read": True, + "write": True, + "sandbox": True, + "checkpoint": False, + }, + }, + { + "name": "devcontainer-instance", + "description": ( + "A devcontainer execution environment auto-discovered " + "from .devcontainer/ configuration." + ), + "resource_kind": "physical", + "sandbox_strategy": "snapshot", + "user_addable": True, + "built_in": True, + "inherits": "container-instance", + "cli_args": [ + { + "name": "path", + "type": "path", + "required": True, + "description": ( + "Path to the directory containing .devcontainer/devcontainer.json." + ), + }, + ], + "parent_types": ["git-checkout", "fs-directory"], + "child_types": ["devcontainer-file"], + "auto_discovery": { + "trigger_types": ["git-checkout", "fs-directory"], + "scan_paths": [ + ".devcontainer/devcontainer.json", + ".devcontainer.json", + ], + }, + "handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"), + "capabilities": { + "read": True, + "write": True, + "sandbox": True, + "checkpoint": False, + }, + }, + { + "name": "devcontainer-file", + "description": ("A single devcontainer.json configuration file resource."), + "resource_kind": "physical", + "sandbox_strategy": "copy_on_write", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": ["devcontainer-instance"], + "child_types": [], + "handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"), + "capabilities": { + "read": True, + "write": False, + "sandbox": False, + "checkpoint": False, + }, + }, ] diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index 5256f78f8..ba6cee0fd 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -442,6 +442,12 @@ def resource_add( str | None, typer.Option("--description", "-d", help="Resource description"), ] = None, + image: Annotated[ + str | None, + typer.Option( + "--image", help="Container image for container-instance resources" + ), + ] = None, read_only: Annotated[ bool, typer.Option("--read-only", help="Mark resource as read-only"), @@ -456,6 +462,8 @@ def resource_add( Examples: agents resource add git-checkout local/my-repo --path /home/user/repo agents resource add fs-directory local/data --path /data --read-only + agents resource add devcontainer-instance local/dc --path /project + agents resource add container-instance local/ctr --image ubuntu:latest """ try: service = _get_registry_service() @@ -466,6 +474,8 @@ def resource_add( properties["path"] = path if branch is not None: properties["branch"] = branch + if image is not None: + properties["image"] = image resource = service.register_resource( type_name=type_name, diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index 058dd3c02..b64023a3d 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -243,6 +243,9 @@ class ResourceTypeSpec(BaseModel): "fs-directory", "fs-mount", "fs-file", + "container-instance", + "devcontainer-instance", + "devcontainer-file", } ) diff --git a/src/cleveragents/resource/handlers/__init__.py b/src/cleveragents/resource/handlers/__init__.py index 57ae71361..6388d52c5 100644 --- a/src/cleveragents/resource/handlers/__init__.py +++ b/src/cleveragents/resource/handlers/__init__.py @@ -14,10 +14,11 @@ which defines a single ``resolve`` method returning a ## Built-in Handlers -| Handler | Resource Type | Sandbox Strategy | -|-----------------------|------------------|------------------| -| ``GitCheckoutHandler``| ``git-checkout`` | ``git_worktree`` | -| ``FsDirectoryHandler``| ``fs-directory`` | ``copy_on_write``| +| Handler | Resource Type | Sandbox Strategy | +|--------------------------|--------------------------|------------------| +| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` | +| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write``| +| ``DevcontainerHandler`` | ``devcontainer-instance``| ``snapshot`` | ## Handler Resolution @@ -26,6 +27,7 @@ Handler strings stored on :class:`ResourceTypeSpec` use the format dynamically imports the module and returns an instance. """ +from cleveragents.resource.handlers.devcontainer import DevcontainerHandler from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler from cleveragents.resource.handlers.protocol import ResourceHandler @@ -35,6 +37,7 @@ from cleveragents.resource.handlers.resolver import ( ) __all__ = [ + "DevcontainerHandler", "FsDirectoryHandler", "GitCheckoutHandler", "HandlerResolutionError", diff --git a/src/cleveragents/resource/handlers/devcontainer.py b/src/cleveragents/resource/handlers/devcontainer.py new file mode 100644 index 000000000..87b6d2f7b --- /dev/null +++ b/src/cleveragents/resource/handlers/devcontainer.py @@ -0,0 +1,38 @@ +"""Devcontainer resource handler for CleverAgents. + +Resolves ``devcontainer-instance`` and ``devcontainer-file`` resources +into sandbox-backed :class:`BoundResource` instances using the +``snapshot`` sandbox strategy. + +The handler: + +1. Validates that the resource has a non-empty ``location``. +2. Determines the sandbox strategy: resource-level override takes + precedence over the default ``snapshot``. +3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision + (or reuse) an isolated snapshot. +4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the + sandbox root. + +Based on: + - docs/specification.md -- Devcontainer, Resource Types + - ADR-043: Devcontainer Integration + - ADR-042: Resource Type Inheritance +""" + +from __future__ import annotations + +from cleveragents.domain.models.core.resource import SandboxStrategy +from cleveragents.resource.handlers._base import BaseResourceHandler + + +class DevcontainerHandler(BaseResourceHandler): + """Handler for ``devcontainer-instance`` and ``devcontainer-file`` types. + + Provisions a snapshot sandbox for devcontainer resources. + ``devcontainer-instance`` inherits from ``container-instance`` + per ADR-042 (resource type inheritance). + """ + + _default_strategy = SandboxStrategy.SNAPSHOT + _type_label = "devcontainer" diff --git a/src/cleveragents/resource/handlers/discovery.py b/src/cleveragents/resource/handlers/discovery.py new file mode 100644 index 000000000..58c22ba87 --- /dev/null +++ b/src/cleveragents/resource/handlers/discovery.py @@ -0,0 +1,173 @@ +"""Devcontainer auto-discovery for CleverAgents. + +Scans a resource's location for ``.devcontainer/`` directories and +root-level ``.devcontainer.json`` files. When found, creates child +``devcontainer-instance`` and ``devcontainer-file`` resources in the +resource registry. + +Auto-discovery triggers when a ``git-checkout`` or ``fs-directory`` +resource is linked to a project. The scanner walks the top level +of the resource location (no recursive descent) and validates each +``devcontainer.json`` before registration. + +Based on: + - docs/specification.md -- Devcontainer, Auto-Discovery + - ADR-043: Devcontainer Integration +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +#: Resource types that trigger devcontainer auto-discovery. +_TRIGGER_TYPES: frozenset[str] = frozenset( + {"git-checkout", "fs-directory"}, +) + +#: Paths to scan relative to the resource location. +_SCAN_PATHS: tuple[str, ...] = ( + ".devcontainer/devcontainer.json", + ".devcontainer.json", +) + + +class DevcontainerDiscoveryResult: + """Describes a discovered devcontainer configuration. + + Attributes: + config_path: Absolute path to the ``devcontainer.json`` file. + config_data: Parsed JSON content of the devcontainer config. + parent_location: Location of the parent resource. + """ + + __slots__ = ("config_data", "config_path", "parent_location") + + def __init__( + self, + *, + config_path: Path, + config_data: dict[str, object], + parent_location: str, + ) -> None: + if not isinstance(config_path, Path): + raise TypeError( + f"config_path must be a Path, got {type(config_path).__name__}" + ) + if not isinstance(config_data, dict): + raise TypeError( + f"config_data must be a dict, got {type(config_data).__name__}" + ) + if not parent_location: + raise ValueError("parent_location must not be empty") + self.config_path = config_path + self.config_data = config_data + self.parent_location = parent_location + + +def discover_devcontainers( + resource_location: str, + resource_type: str, +) -> list[DevcontainerDiscoveryResult]: + """Scan a resource location for devcontainer configurations. + + Only scans the well-known paths (``.devcontainer/devcontainer.json`` + and root ``.devcontainer.json``). Each file is validated as JSON + before being included in the results. + + Args: + resource_location: Filesystem path of the parent resource. + resource_type: Type name of the parent resource. + + Returns: + List of discovery results, one per valid devcontainer config. + Invalid JSON files are skipped with a warning log. + + Raises: + ValueError: If *resource_location* is empty or + *resource_type* is not a trigger type. + """ + if not resource_location: + raise ValueError("resource_location must not be empty") + if not resource_type: + raise ValueError("resource_type must not be empty") + + if resource_type not in _TRIGGER_TYPES: + return [] + + base = Path(resource_location) + if not base.is_dir(): + logger.debug( + "Resource location is not a directory, skipping devcontainer discovery: %s", + resource_location, + ) + return [] + + results: list[DevcontainerDiscoveryResult] = [] + for relative in _SCAN_PATHS: + candidate = base / relative + if not candidate.is_file(): + continue + + config_data = _load_devcontainer_json(candidate) + if config_data is None: + continue + + results.append( + DevcontainerDiscoveryResult( + config_path=candidate, + config_data=config_data, + parent_location=resource_location, + ) + ) + + return results + + +def is_trigger_type(resource_type: str) -> bool: + """Check whether a resource type triggers devcontainer discovery. + + Args: + resource_type: The resource type name to check. + + Returns: + ``True`` if the type triggers auto-discovery. + """ + if not resource_type: + raise ValueError("resource_type must not be empty") + return resource_type in _TRIGGER_TYPES + + +def _load_devcontainer_json(path: Path) -> dict[str, object] | None: + """Read and validate a devcontainer.json file. + + Args: + path: Absolute path to the JSON file. + + Returns: + Parsed dict on success, ``None`` on invalid JSON. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("Cannot read devcontainer config %s: %s", path, exc) + return None + + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + logger.warning("Invalid JSON in devcontainer config %s: %s", path, exc) + return None + + if not isinstance(data, dict): + logger.warning( + "Devcontainer config %s is not a JSON object (got %s)", + path, + type(data).__name__, + ) + return None + + return data