Files
cleveragents-core/robot/container_tool_exec.robot
T
CoreRasurae 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
feat(devcontainer): add container-aware tool execution and I/O forwarding
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
2026-03-12 10:31:37 +00:00

158 lines
8.5 KiB
Plaintext

*** Settings ***
Documentation Integration tests for container-aware tool execution (#515)
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${TIMEOUT} 60s
*** Test Cases ***
PathMapper Maps Host To Container
[Documentation] Verify PathMapper translates host paths to container paths
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.path_mapper import PathMapper
... m = PathMapper('/tmp/sandbox', '/workspace')
... result = m.host_to_container('/tmp/sandbox/src/f.py')
... assert result == '/workspace/src/f.py', f"got {result}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 PathMapper host_to_container failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
PathMapper Maps Container To Host
[Documentation] Verify PathMapper translates container paths to host paths
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.path_mapper import PathMapper
... m = PathMapper('/tmp/sandbox', '/workspace')
... result = m.container_to_host('/workspace/src/f.py')
... assert result == '/tmp/sandbox/src/f.py', f"got {result}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 PathMapper container_to_host failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
PathMapper Leaves External Paths Unmapped
[Documentation] Paths outside root are returned unchanged
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.path_mapper import PathMapper
... m = PathMapper('/tmp/sandbox', '/workspace')
... result = m.host_to_container('/usr/lib/x')
... assert result == '/usr/lib/x', f"got {result}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 PathMapper external path mapping failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ContainerConfig Has Defaults
[Documentation] ContainerConfig defaults to /workspace and 120s timeout
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.container_executor import ContainerConfig
... c = ContainerConfig()
... assert c.workspace_folder == '/workspace', f"got {c.workspace_folder}"
... assert c.timeout_seconds == 120, f"got {c.timeout_seconds}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ContainerConfig defaults test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ContainerMetadata Is Frozen
[Documentation] ContainerMetadata is a frozen Pydantic model
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.container_executor import ContainerMetadata
... m = ContainerMetadata(container_id='x', image='y')
... assert m.container_id == 'x', f"got {m.container_id}"
... assert m.image == 'y', f"got {m.image}"
... assert m.timed_out is False
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ContainerMetadata test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ContainerToolExecutor Instantiation
[Documentation] ContainerToolExecutor can be created with ContainerConfig
${script}= Catenate SEPARATOR=\n
... import structlog, sys; structlog.configure(logger_factory=structlog.PrintLoggerFactory(file=sys.stderr))
... from cleveragents.tool.container_executor import ContainerConfig, ContainerToolExecutor
... c = ContainerConfig(container_id='t')
... e = ContainerToolExecutor(c)
... assert e.config.container_id == 't', f"got {e.config.container_id}"
... assert e.path_mapper is not None
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 Executor instantiation failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ContainerExecutionError Carries Details
[Documentation] ContainerExecutionError stores exit_code and stderr
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.container_executor import ContainerExecutionError
... e = ContainerExecutionError('fail', exit_code=2, stderr='bad')
... assert e.exit_code == 2, f"got {e.exit_code}"
... assert e.stderr == 'bad', f"got {e.stderr}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ContainerExecutionError test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ContainerTimeoutError Carries Timeout
[Documentation] ContainerTimeoutError records timeout_seconds
${script}= Catenate SEPARATOR=\n
... from cleveragents.tool.container_executor import ContainerTimeoutError
... e = ContainerTimeoutError(30)
... assert e.timeout_seconds == 30, f"got {e.timeout_seconds}"
... assert e.timed_out is True
... assert '30' in str(e)
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ContainerTimeoutError test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ToolInvocation Has ContainerMetadata Field
[Documentation] ToolInvocation accepts container_metadata dict
${script}= Catenate SEPARATOR=\n
... from cleveragents.domain.models.core.change import ToolInvocation
... t = ToolInvocation(
... plan_id='01TESTPLANID000000000000000',
... tool_name='ns/t',
... container_metadata={'container_id': 'x'},
... )
... assert t.container_metadata['container_id'] == 'x'
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ToolInvocation container_metadata test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK
ToolRunner Container Routing Without Executor Returns Error
[Documentation] ToolRunner without ContainerToolExecutor returns error for container env
${script}= Catenate SEPARATOR=\n
... from unittest.mock import MagicMock
... from cleveragents.tool.runner import ToolRunner
... from cleveragents.tool.registry import ToolRegistry
... from cleveragents.tool.runtime import ToolSpec
... from cleveragents.domain.models.core.plan import ExecutionEnvironment
... from cleveragents.application.services.execution_environment_resolver import ExecutionEnvironmentResolver
... r = MagicMock(spec=ToolRegistry)
... s = ToolSpec(name='t', description='d', input_schema={}, handler=lambda x: x)
... r.get.return_value = s
... ev = MagicMock(spec=ExecutionEnvironmentResolver)
... ev.resolve_and_validate.return_value = ExecutionEnvironment.CONTAINER
... runner = ToolRunner(registry=r, execution_environment_resolver=ev)
... res = runner.execute('t', {})
... assert not res.success, f"Expected failure but got success"
... assert 'ContainerToolExecutor' in res.error, f"Expected ContainerToolExecutor in error: {res.error}"
... print("OK")
${result}= Run Process ${PYTHON} -c ${script}
... timeout=${TIMEOUT} env:PYTHONPATH=${CURDIR}/../src
Should Be Equal As Integers ${result.rc} 0 ToolRunner container routing test failed: ${result.stderr}
Should Be Equal As Strings ${result.stdout.strip()} OK