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
514 lines
14 KiB
Python
514 lines
14 KiB
Python
"""Step definitions for changeset capture feature tests."""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import UTC, datetime
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.domain.models.core.change import (
|
|
ChangeEntry,
|
|
ChangeOperation,
|
|
InMemoryChangeSetStore,
|
|
SpecChangeSet,
|
|
)
|
|
from cleveragents.tool.builtins.changeset import (
|
|
ChangeSetCapture,
|
|
ChangeSetEntry,
|
|
)
|
|
|
|
# ---- ChangeOperation enum ----
|
|
|
|
|
|
@given("I import the ChangeOperation enum")
|
|
def step_import_change_operation(context):
|
|
"""Import ChangeOperation enum."""
|
|
context.enum_cls = ChangeOperation
|
|
|
|
|
|
@then("it should have members CREATE, MODIFY, DELETE, RENAME")
|
|
def step_verify_enum_members(context):
|
|
"""Verify enum members."""
|
|
assert hasattr(context.enum_cls, "CREATE")
|
|
assert hasattr(context.enum_cls, "MODIFY")
|
|
assert hasattr(context.enum_cls, "DELETE")
|
|
assert hasattr(context.enum_cls, "RENAME")
|
|
assert context.enum_cls.CREATE == "create"
|
|
assert context.enum_cls.MODIFY == "modify"
|
|
assert context.enum_cls.DELETE == "delete"
|
|
assert context.enum_cls.RENAME == "rename"
|
|
|
|
|
|
# ---- ChangeEntry model ----
|
|
|
|
|
|
@given("I import the ChangeEntry model")
|
|
def step_import_change_entry(context):
|
|
"""Import ChangeEntry model."""
|
|
context.entry_cls = ChangeEntry
|
|
|
|
|
|
@when("I create a ChangeEntry for a create operation")
|
|
def step_create_entry_create(context):
|
|
"""Create a ChangeEntry for create."""
|
|
context.entry = ChangeEntry(
|
|
plan_id="01HXYZ",
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-write",
|
|
operation=ChangeOperation.CREATE,
|
|
path="src/new_file.py",
|
|
after_hash="abc123",
|
|
)
|
|
|
|
|
|
@then("the entry should have a ULID entry_id")
|
|
def step_entry_has_ulid(context):
|
|
"""Verify ULID entry_id."""
|
|
assert context.entry.entry_id
|
|
assert len(context.entry.entry_id) > 0
|
|
|
|
|
|
@then('the entry should have operation "{op}"')
|
|
def step_entry_operation(context, op):
|
|
"""Verify operation value."""
|
|
assert context.entry.operation == op
|
|
|
|
|
|
@then("the entry should have a UTC timestamp")
|
|
def step_entry_utc_timestamp(context):
|
|
"""Verify UTC timestamp."""
|
|
ts = context.entry.timestamp
|
|
assert isinstance(ts, datetime)
|
|
assert ts.tzinfo is not None
|
|
assert ts.tzinfo in (UTC, UTC)
|
|
|
|
|
|
@then("the before_hash should be None")
|
|
def step_before_hash_none(context):
|
|
"""Verify before_hash is None."""
|
|
assert context.entry.before_hash is None
|
|
|
|
|
|
@when("I create a ChangeEntry for a modify operation with hashes")
|
|
def step_create_entry_modify(context):
|
|
"""Create a ChangeEntry for modify with hashes."""
|
|
context.entry = ChangeEntry(
|
|
plan_id="01HXYZ",
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-edit",
|
|
operation=ChangeOperation.MODIFY,
|
|
path="src/existing.py",
|
|
before_hash="hash-before",
|
|
after_hash="hash-after",
|
|
)
|
|
|
|
|
|
@then("the before_hash should not be None")
|
|
def step_before_hash_not_none(context):
|
|
"""Verify before_hash is set."""
|
|
assert context.entry.before_hash is not None
|
|
|
|
|
|
@then("the after_hash should not be None")
|
|
def step_after_hash_not_none(context):
|
|
"""Verify after_hash is set."""
|
|
assert context.entry.after_hash is not None
|
|
|
|
|
|
@when("I create a ChangeEntry for a delete operation")
|
|
def step_create_entry_delete(context):
|
|
"""Create a ChangeEntry for delete."""
|
|
context.entry = ChangeEntry(
|
|
plan_id="01HXYZ",
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-delete",
|
|
operation=ChangeOperation.DELETE,
|
|
path="src/old_file.py",
|
|
before_hash="hash-of-deleted",
|
|
)
|
|
|
|
|
|
@then("the after_hash should be None")
|
|
def step_after_hash_none(context):
|
|
"""Verify after_hash is None."""
|
|
assert context.entry.after_hash is None
|
|
|
|
|
|
@when("I create a ChangeEntry for a rename operation")
|
|
def step_create_entry_rename(context):
|
|
"""Create a ChangeEntry for rename."""
|
|
context.entry = ChangeEntry(
|
|
plan_id="01HXYZ",
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-move",
|
|
operation=ChangeOperation.RENAME,
|
|
path="src/old_name.py",
|
|
before_hash="hash-x",
|
|
after_hash="hash-x",
|
|
)
|
|
|
|
|
|
# ---- SpecChangeSet model ----
|
|
|
|
|
|
@given("I import the SpecChangeSet model")
|
|
def step_import_spec_changeset(context):
|
|
"""Import SpecChangeSet."""
|
|
context.cs_cls = SpecChangeSet
|
|
|
|
|
|
def _make_mixed_entries():
|
|
"""Build a list of entries with mixed operations."""
|
|
base = {
|
|
"plan_id": "plan-1",
|
|
"resource_id": "res-A",
|
|
"tool_name": "builtin/file-write",
|
|
}
|
|
return [
|
|
ChangeEntry(
|
|
**base,
|
|
operation=ChangeOperation.CREATE,
|
|
path="a.py",
|
|
),
|
|
ChangeEntry(
|
|
**base,
|
|
operation=ChangeOperation.MODIFY,
|
|
path="b.py",
|
|
),
|
|
ChangeEntry(
|
|
plan_id="plan-1",
|
|
resource_id="res-B",
|
|
tool_name="builtin/file-edit",
|
|
operation=ChangeOperation.MODIFY,
|
|
path="c.py",
|
|
),
|
|
ChangeEntry(
|
|
**base,
|
|
operation=ChangeOperation.DELETE,
|
|
path="d.py",
|
|
),
|
|
ChangeEntry(
|
|
**base,
|
|
operation=ChangeOperation.RENAME,
|
|
path="e.py",
|
|
),
|
|
]
|
|
|
|
|
|
@when("I create a SpecChangeSet with mixed operations")
|
|
def step_create_mixed_changeset(context):
|
|
"""Create a SpecChangeSet with mixed operations."""
|
|
context.spec_cs = SpecChangeSet(
|
|
plan_id="plan-1",
|
|
entries=_make_mixed_entries(),
|
|
)
|
|
|
|
|
|
@then("the creates count should be {n:d}")
|
|
def step_creates_count(context, n):
|
|
"""Verify creates count."""
|
|
assert context.spec_cs.creates == n
|
|
|
|
|
|
@then("the modifies count should be {n:d}")
|
|
def step_modifies_count(context, n):
|
|
"""Verify modifies count."""
|
|
assert context.spec_cs.modifies == n
|
|
|
|
|
|
@then("the deletes count should be {n:d}")
|
|
def step_deletes_count(context, n):
|
|
"""Verify deletes count."""
|
|
assert context.spec_cs.deletes == n
|
|
|
|
|
|
@then("the renames count should be {n:d}")
|
|
def step_renames_count(context, n):
|
|
"""Verify renames count."""
|
|
assert context.spec_cs.renames == n
|
|
|
|
|
|
@then("paths_changed should have {n:d} entries")
|
|
def step_paths_changed(context, n):
|
|
"""Verify paths_changed count."""
|
|
assert len(context.spec_cs.paths_changed) == n
|
|
|
|
|
|
@then("resources_involved should have {n:d} entries")
|
|
def step_resources_involved(context, n):
|
|
"""Verify resources_involved count."""
|
|
assert len(context.spec_cs.resources_involved) == n
|
|
|
|
|
|
@then("the summary dict should have correct totals")
|
|
def step_summary_dict(context):
|
|
"""Verify summary dict."""
|
|
s = context.spec_cs.summary()
|
|
assert s["total"] == 5
|
|
assert s["creates"] == 1
|
|
assert s["modifies"] == 2
|
|
assert s["deletes"] == 1
|
|
assert s["renames"] == 1
|
|
assert s["paths_changed"] == 5
|
|
assert s["resources_involved"] == 2
|
|
|
|
|
|
@when("I create an empty SpecChangeSet")
|
|
def step_create_empty_changeset(context):
|
|
"""Create an empty SpecChangeSet."""
|
|
context.spec_cs = SpecChangeSet(plan_id="plan-empty")
|
|
|
|
|
|
# ---- InMemoryChangeSetStore ----
|
|
|
|
|
|
@given("I have an InMemoryChangeSetStore")
|
|
def step_create_store(context):
|
|
"""Create InMemoryChangeSetStore."""
|
|
context.store = InMemoryChangeSetStore()
|
|
context.changeset_ids = []
|
|
|
|
|
|
@when('I start a changeset for plan "{plan_id}"')
|
|
def step_start_changeset(context, plan_id):
|
|
"""Start a changeset."""
|
|
cs_id = context.store.start(plan_id)
|
|
context.changeset_ids.append(cs_id)
|
|
context.last_cs_id = cs_id
|
|
|
|
|
|
@when("I record a create entry in the changeset")
|
|
def step_record_create_entry(context):
|
|
"""Record a create entry."""
|
|
# Use the plan_id from the changeset so the entry matches
|
|
# (InMemoryChangeSetStore.record now validates via add_change).
|
|
cs = context.store.get(context.last_cs_id)
|
|
plan_id = cs.plan_id if cs is not None else "plan-abc"
|
|
entry = ChangeEntry(
|
|
plan_id=plan_id,
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-write",
|
|
operation=ChangeOperation.CREATE,
|
|
path="new.py",
|
|
)
|
|
context.store.record(context.last_cs_id, entry)
|
|
|
|
|
|
@then("I can get the changeset by ID")
|
|
def step_get_changeset(context):
|
|
"""Verify get returns the changeset."""
|
|
cs = context.store.get(context.last_cs_id)
|
|
assert cs is not None
|
|
context.fetched_cs = cs
|
|
|
|
|
|
@then("the store changeset should have {n:d} entry")
|
|
def step_store_changeset_entry_count(context, n):
|
|
"""Verify entry count in store."""
|
|
cs = context.store.get(context.last_cs_id)
|
|
assert cs is not None
|
|
assert len(cs.entries) == n
|
|
|
|
|
|
@then('get_for_plan "{plan_id}" should return {n:d} changesets')
|
|
def step_get_for_plan_count(context, plan_id, n):
|
|
"""Verify get_for_plan returns expected count."""
|
|
result = context.store.get_for_plan(plan_id)
|
|
assert len(result) == n
|
|
|
|
|
|
@then('get_for_plan "{plan_id}" should return {n:d} changeset')
|
|
def step_get_for_plan_singular(context, plan_id, n):
|
|
"""Verify get_for_plan returns expected count (singular)."""
|
|
result = context.store.get_for_plan(plan_id)
|
|
assert len(result) == n
|
|
|
|
|
|
@when("I record a modify entry in the changeset")
|
|
def step_record_modify_entry(context):
|
|
"""Record a modify entry."""
|
|
entry = ChangeEntry(
|
|
plan_id="plan-s",
|
|
resource_id="res-1",
|
|
tool_name="builtin/file-edit",
|
|
operation=ChangeOperation.MODIFY,
|
|
path="existing.py",
|
|
before_hash="aaa",
|
|
after_hash="bbb",
|
|
)
|
|
context.store.record(context.last_cs_id, entry)
|
|
|
|
|
|
@then("the summarized changeset should show {n:d} total")
|
|
def step_summarize_total(context, n):
|
|
"""Verify summarize total."""
|
|
s = context.store.summarize(context.last_cs_id)
|
|
assert s["total"] == n
|
|
|
|
|
|
@then("getting a non-existent changeset returns None")
|
|
def step_get_nonexistent(context):
|
|
"""Verify get returns None for unknown ID."""
|
|
assert context.store.get("nonexistent-id") is None
|
|
|
|
|
|
@then("recording to a non-existent changeset raises KeyError")
|
|
def step_record_nonexistent(context):
|
|
"""Verify recording to unknown ID raises KeyError."""
|
|
entry = ChangeEntry(
|
|
plan_id="plan-x",
|
|
resource_id="res-1",
|
|
tool_name="t",
|
|
operation=ChangeOperation.CREATE,
|
|
path="x.py",
|
|
)
|
|
try:
|
|
context.store.record("nonexistent-id", entry)
|
|
raise AssertionError("Expected KeyError")
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
@then("summarizing a non-existent changeset returns empty dict")
|
|
def step_summarize_nonexistent(context):
|
|
"""Verify summarize returns {} for unknown ID."""
|
|
assert context.store.summarize("nonexistent-id") == {}
|
|
|
|
|
|
# ---- ChangeSetCapture integration ----
|
|
|
|
|
|
@given('I have a ChangeSetCapture with resource_id "{rid}"')
|
|
def step_capture_with_resource(context, rid):
|
|
"""Create ChangeSetCapture with resource_id."""
|
|
context.capture = ChangeSetCapture(
|
|
plan_id="plan-cap",
|
|
resource_id=rid,
|
|
)
|
|
|
|
|
|
@when("I create a ChangeSetEntry via capture")
|
|
def step_create_via_capture(context):
|
|
"""Add an entry via the capture object."""
|
|
entry = ChangeSetEntry(
|
|
operation="create",
|
|
path="test.py",
|
|
resource_id=None,
|
|
tool_name="builtin/file-write",
|
|
)
|
|
context.capture._entries.append(entry)
|
|
# Resource ID comes from capture default
|
|
context.last_entry = context.capture.get_changeset().entries[-1]
|
|
|
|
|
|
@then('the entry resource_id should be "{rid}"')
|
|
def step_entry_resource_id(context, rid):
|
|
"""Verify resource_id is empty — set on domain convert."""
|
|
# The lightweight entry may have None; the capture stores
|
|
# the resource_id on the capture object itself.
|
|
assert context.capture._resource_id == rid
|
|
|
|
|
|
@then("the entry tool_name should be set")
|
|
def step_entry_tool_name_set(context):
|
|
"""Verify tool_name is set."""
|
|
assert context.last_entry.tool_name is not None
|
|
|
|
|
|
@given("I have a ChangeSetCapture with sandbox_root")
|
|
def step_capture_with_sandbox(context):
|
|
"""Create capture with a sandbox root."""
|
|
context.tmpdir = tempfile.mkdtemp()
|
|
context.capture = ChangeSetCapture(
|
|
plan_id="plan-sb",
|
|
resource_id="res-sb",
|
|
sandbox_root=context.tmpdir,
|
|
)
|
|
|
|
|
|
@when("I capture a write to a nested path")
|
|
def step_capture_nested_write(context):
|
|
"""Simulate capturing a nested path write."""
|
|
from cleveragents.tool.builtins.changeset import (
|
|
_normalize_path,
|
|
)
|
|
|
|
context.normalized = _normalize_path(
|
|
"sub/dir/file.py", context.capture._sandbox_root
|
|
)
|
|
|
|
|
|
@then("the captured path should be repo-relative")
|
|
def step_path_repo_relative(context):
|
|
"""Verify the path is relative."""
|
|
assert not os.path.isabs(context.normalized)
|
|
assert context.normalized == os.path.join("sub", "dir", "file.py")
|
|
|
|
|
|
@when("I add several entries via capture")
|
|
def step_add_entries_via_capture(context):
|
|
"""Add several entries directly."""
|
|
for op in ("create", "modify", "delete"):
|
|
entry = ChangeSetEntry(
|
|
operation=op,
|
|
path=f"{op}_file.py",
|
|
resource_id="res-2",
|
|
tool_name="builtin/file-write",
|
|
)
|
|
context.capture._entries.append(entry)
|
|
|
|
|
|
@then("to_spec_changeset should return a SpecChangeSet")
|
|
def step_spec_changeset_conversion(context):
|
|
"""Verify spec changeset conversion."""
|
|
spec = context.capture.to_spec_changeset()
|
|
assert isinstance(spec, SpecChangeSet)
|
|
assert len(spec.entries) == 3
|
|
assert spec.plan_id == "plan-cap"
|
|
|
|
|
|
# ---- Multi-resource plan ----
|
|
|
|
|
|
@given("I have two ChangeSetCapture instances for different resources")
|
|
def step_two_captures(context):
|
|
"""Create two captures for different resources."""
|
|
context.capture_a = ChangeSetCapture(
|
|
plan_id="plan-multi",
|
|
resource_id="res-A",
|
|
)
|
|
context.capture_b = ChangeSetCapture(
|
|
plan_id="plan-multi",
|
|
resource_id="res-B",
|
|
)
|
|
|
|
|
|
@when("each capture records changes for its resource")
|
|
def step_record_per_resource(context):
|
|
"""Record one entry in each capture."""
|
|
entry_a = ChangeSetEntry(
|
|
operation="create",
|
|
path="a.py",
|
|
resource_id="res-A",
|
|
tool_name="builtin/file-write",
|
|
)
|
|
entry_b = ChangeSetEntry(
|
|
operation="modify",
|
|
path="b.py",
|
|
resource_id="res-B",
|
|
tool_name="builtin/file-edit",
|
|
)
|
|
context.capture_a._entries.append(entry_a)
|
|
context.capture_b._entries.append(entry_b)
|
|
|
|
|
|
@then("each changeset should only have its resource entries")
|
|
def step_verify_per_resource(context):
|
|
"""Verify each changeset has only its resource."""
|
|
cs_a = context.capture_a.get_changeset()
|
|
cs_b = context.capture_b.get_changeset()
|
|
assert len(cs_a.entries) == 1
|
|
assert cs_a.entries[0].resource_id == "res-A"
|
|
assert len(cs_b.entries) == 1
|
|
assert cs_b.entries[0].resource_id == "res-B"
|