From ae74170be1113c530c47069b80c7c49b96a2be8d Mon Sep 17 00:00:00 2001 From: hrjang Date: Tue, 11 Aug 2026 18:58:34 +0900 Subject: [PATCH] =?UTF-8?q?feat!:=20GAPT=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=83=8C=EB=93=9C=EB=B0=95=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20XGeny=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=84=B8=EC=85=98=EC=9C=BC=EB=A1=9C=20(3.0.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XGEN 은 GAPT 와 무관하다. 그런데 샌드박스 표면 전체가 GAPT 의 `docker exec` 전제 위에 얹혀 있었고, **XGEN 의 어떤 호스트도 그것을 쓰지 않았다** — xgen-workflow 가 런타임에서 가져오는 것을 전수 확인한 결과 `tools._sandbox`, `sandbox_exec_tool`, `build_container_cli_client`, `ContainerCLIRunner` 사용처 0건. 남겨 두면 새 실행 기반을 그 위에 또 얹게 된다. 제거 - tools/_sandbox.py — docker exec 전송 + 호스트↔컨테이너 경로 변환 3종 - ContainerCLIRunner / SandboxHandle / build_container_cli_client - Pipeline.attach_runtime(containerize_cli=) — 샌드박스가 LLM 클라이언트의 스폰 방식을 바꾸던 결합. 이제 어떤 프로바이더든 클라이언트는 호스트에서 돌고, 샌드박스에는 도구를 통해서만 닿는다. 백엔드마다 격리 방식이 갈리면 "이 백엔드에서만 되는 도구"가 생긴다. 추가 — tools/_xgeny_sandbox.py - XgenySandbox 프로토콜(workdir + async ensure/exec/read_bytes/write_bytes) - 파일 읽기·쓰기가 1급 연산이다. GAPT 는 cat / sh -c 'cat > …' 서브프로세스로 흉내냈는데, 그러면 파일 하나 읽는 데 프로세스가 뜨고 "없는 파일"과 "권한 없음"이 똑같이 "명령 실패"로 뭉개진다. - 경로 가드가 sandbox_path 한 곳에 있다 — 세션 밖으로 나가는 경로는 전부 여기를 지난다. 도구마다 각자 막으면 새 도구가 매번 빠뜨린다. - 경로 변환 계층이 없다: 호스트가 양쪽 루트를 같은 문자열로 맞춘다. 변경 - 내장 도구 7종(Bash/Read/Write/Edit/Glob/Grep/workspace_*)이 새 프리미티브를 쓴다. 분기 조건(if context.sandbox is not None)은 그대로 — 앞으로 추가될 도구도 같은 자리에서 갈라진다. - SandboxExecTool 이 XgenySandbox 로 실행한다 (계약·직렬화 형식 불변). 4,732 passed, 15 skipped, ruff clean. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 44 ++++ pyproject.toml | 2 +- src/xgen_agent_runtime/__init__.py | 6 - src/xgen_agent_runtime/core/pipeline.py | 62 +----- src/xgen_agent_runtime/llm_client/__init__.py | 6 - .../llm_client/_cli_runtime.py | 104 --------- .../llm_client/claude_code.py | 55 +---- src/xgen_agent_runtime/tools/__init__.py | 28 ++- src/xgen_agent_runtime/tools/_sandbox.py | 199 ------------------ .../tools/_xgeny_sandbox.py | 175 +++++++++++++++ src/xgen_agent_runtime/tools/base.py | 12 +- .../tools/built_in/bash_tool.py | 4 +- .../tools/built_in/edit_tool.py | 2 +- .../tools/built_in/glob_tool.py | 2 +- .../tools/built_in/grep_tool.py | 2 +- .../tools/built_in/read_tool.py | 4 +- .../tools/built_in/sandbox_exec_tool.py | 23 +- .../tools/built_in/workspace_tools.py | 10 +- .../tools/built_in/write_tool.py | 4 +- .../llm_client/unit/test_container_runner.py | 146 ------------- tests/unit/test_sandbox_attach.py | 104 +++------ tests/unit/test_sandbox_exec_tool.py | 82 ++++---- tests/unit/test_sandbox_path_mapping.py | 101 --------- tests/unit/test_sandbox_tools.py | 122 ----------- tests/unit/test_workspace_tools.py | 8 +- tests/unit/test_xgeny_sandbox_tools.py | 155 ++++++++++++++ 26 files changed, 512 insertions(+), 950 deletions(-) delete mode 100644 src/xgen_agent_runtime/tools/_sandbox.py create mode 100644 src/xgen_agent_runtime/tools/_xgeny_sandbox.py delete mode 100644 tests/llm_client/unit/test_container_runner.py delete mode 100644 tests/unit/test_sandbox_path_mapping.py delete mode 100644 tests/unit/test_sandbox_tools.py create mode 100644 tests/unit/test_xgeny_sandbox_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7733820..5da3672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [3.0.0] — 2026-08-11 + +### Removed — GAPT 컨테이너 샌드박스 (BREAKING) + +XGEN 은 GAPT 와 무관하다. 그런데 샌드박스 표면 전체가 GAPT 의 `docker exec` +전제 위에 얹혀 있었고, **XGEN 의 어떤 호스트도 그것을 쓰지 않았다** (xgen-workflow +의 import 를 전수 확인: 사용처 0건). 남겨 두면 새 실행 기반을 그 위에 또 얹게 된다. + +- `tools/_sandbox.py` — `docker exec` 전송 + 호스트↔컨테이너 경로 변환 3종 + (`resolve_container_workdir` / `map_into_container` / `container_path`) +- `llm_client._cli_runtime.ContainerCLIRunner`, `llm_client._cli_runtime.SandboxHandle` +- `llm_client.claude_code.build_container_cli_client` +- `Pipeline.attach_runtime(containerize_cli=)` — 샌드박스가 LLM 클라이언트의 + 스폰 방식을 바꾸던 결합. 이제 **어떤 프로바이더든 클라이언트는 호스트에서 돌고, + 샌드박스에는 도구를 통해서만 닿는다.** 백엔드마다 격리 방식이 갈리면 + "이 백엔드에서만 되는 도구"가 생긴다. +- 공개 export: `sandbox_exec`, `SandboxExecError`, `container_path` + +### Added — XGeny 샌드박스 세션 + +- `tools/_xgeny_sandbox.py` — `XgenySandbox` 프로토콜(`workdir` + async + `ensure`/`exec`/`read_bytes`/`write_bytes`), `ExecResult`, `sandbox_path`. + 런타임은 프로토콜만 알고 그 뒤(HTTP·인프로세스·로컬)는 호스트가 정한다. +- 파일 읽기·쓰기가 **1급 연산**이다. GAPT 는 `cat` / `sh -c 'cat > …'` 서브프로세스로 + 흉내냈는데, 그러면 파일 하나 읽는 데 프로세스가 뜨고 "없는 파일"과 "권한 없음"이 + 똑같이 "명령 실패"로 뭉개진다. +- 경로 가드가 `sandbox_path` **한 곳**에 있다 — 세션 밖으로 나가는 경로는 전부 + 여기를 지난다. 도구마다 각자 막으면 새 도구가 매번 빠뜨린다. + +### Changed + +- 내장 도구 7종(Bash/Read/Write/Edit/Glob/Grep/workspace_*)이 새 프리미티브를 쓴다. + 분기 조건(`if context.sandbox is not None`)은 그대로 — 앞으로 추가될 도구도 + 같은 자리에서 갈라진다. +- `SandboxExecTool` 이 `XgenySandbox` 로 실행한다 (계약·직렬화 형식 불변). +- `ToolContext.sandbox` 타입 문서 갱신. 필드 이름과 의미는 그대로다. + +### Migration + +호스트는 `container_name` 대신 `workdir` 을 갖고 `exec`/`read_bytes`/`write_bytes` +를 구현하는 객체를 `ToolContext.sandbox`(또는 `attach_runtime(sandbox=)`)에 넘긴다. +경로 변환 계층은 필요 없다 — 에이전트를 태우는 쪽과 코드를 돌리는 쪽이 같은 절대 +경로를 쓰도록 호스트가 두 루트를 맞춘다. + ## [2.69.0] — 2026-08-11 ### Fixed (ported from geny-executor 2.64.8 ~ 2.65.2) diff --git a/pyproject.toml b/pyproject.toml index edddced..0c3d473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xgen-agent-runtime" -version = "2.69.0" +version = "3.0.0" description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API" readme = "README.md" license = "Apache-2.0" diff --git a/src/xgen_agent_runtime/__init__.py b/src/xgen_agent_runtime/__init__.py index c148316..1a8b109 100644 --- a/src/xgen_agent_runtime/__init__.py +++ b/src/xgen_agent_runtime/__init__.py @@ -103,12 +103,9 @@ ClientCapabilities, ClientRegistry, ConfigError, - ContainerCLIRunner, ContentBlock, CredentialBundle, ProviderCredentials, - SandboxHandle, - build_container_cli_client, ) from xgen_agent_runtime.memory import ( GenyPresets, @@ -206,12 +203,9 @@ "ClientCapabilities", "ClientRegistry", "ConfigError", - "ContainerCLIRunner", "ContentBlock", "CredentialBundle", "ProviderCredentials", - "SandboxHandle", - "build_container_cli_client", # Errors "GenyExecutorError", "PipelineError", diff --git a/src/xgen_agent_runtime/core/pipeline.py b/src/xgen_agent_runtime/core/pipeline.py index ffaab01..b6cf487 100644 --- a/src/xgen_agent_runtime/core/pipeline.py +++ b/src/xgen_agent_runtime/core/pipeline.py @@ -164,12 +164,9 @@ def _creds_to_client_kwargs(provider: str, creds: ProviderCredentials) -> Dict[s "extra_args", "timeout_s", "strict_wire", - # Extra env vars handed to every CLI spawn (host runner AND the - # sandbox ContainerCLIRunner via ``--env``). The host's escape + # Extra env vars handed to every CLI spawn. The host's escape # hatch for credential channels the constructor doesn't model — - # e.g. ``CLAUDE_CODE_OAUTH_TOKEN`` for a long-lived setup token, - # which (unlike the rotating OAuth file) is safe to share across - # many sandbox containers. + # e.g. ``CLAUDE_CODE_OAUTH_TOKEN`` for a long-lived setup token. "env_extras", ): if key in extras: @@ -859,14 +856,10 @@ def __init__( False # flips once run()/run_stream() begins; gates attach_runtime ) self._attached_llm_client: Any = None # set by attach_runtime; propagated in _init_state - self._attached_sandbox: Any = ( - None # SandboxHandle; wraps a resolved claude_code_cli client in a container runner - ) - # When False, an attached sandbox is used for TOOL execution (ctx.sandbox) - # only — the claude_code_cli client is NOT wrapped in a ContainerCLIRunner, - # so the CLI keeps running on the host (OAuth-safe). Tools still run in the - # sandbox via docker exec. Default True preserves full CLI-in-container. - self._containerize_cli: bool = True + # XgenySandbox — the session the agent's TOOLS execute in (ctx.sandbox). + # It never wraps the LLM client: the CLI keeps running here and reaches + # the sandbox through its tools, like every other provider. + self._attached_sandbox: Any = None self._credentials: CredentialBundle = CredentialBundle() # set by from_manifest_async self._subagent_registry: Any = None # set by attach_runtime; populates state + agent stage self._attached_session_runtime: Any = None # v0.30.0 plugin slot; propagated in _init_state @@ -1679,7 +1672,6 @@ def attach_runtime( env_persistence: Optional[Any] = None, pack_persistence: Optional[Any] = None, env_settings_schemas: Optional[Any] = None, - containerize_cli: Optional[bool] = None, override_manifest: bool = False, ) -> None: """Inject session-scoped runtime objects into a manifest-built pipeline. @@ -1835,7 +1827,6 @@ def attach_runtime( env_persistence=env_persistence, pack_persistence=pack_persistence, env_settings_schemas=env_settings_schemas, - containerize_cli=containerize_cli, override_manifest=override_manifest, ) @@ -1898,7 +1889,6 @@ def _apply_runtime( env_persistence: Optional[Any] = None, pack_persistence: Optional[Any] = None, env_settings_schemas: Optional[Any] = None, - containerize_cli: Optional[bool] = None, override_manifest: bool = False, ) -> None: """Shared wiring behind :meth:`attach_runtime` / :meth:`refresh_runtime`. @@ -1943,15 +1933,6 @@ def _apply_runtime( if self._environment is not None: self._environment.attach_pack_persistence(pack_persistence) - if containerize_cli is not None: - # Whether an attached sandbox also runs the claude_code_cli client - # in-container. False → CLI stays on host (OAuth-safe), tools still - # sandboxed. Bump the generation so the client rebuilds accordingly. - if bool(containerize_cli) != self._containerize_cli: - self._client_generation += 1 - self._warm_llm_client = None - self._containerize_cli = bool(containerize_cli) - if env_settings_schemas is not None: # Host descriptor of configurable tool settings (groups + fields + # which are secret) for accurate masking / discovery by env_get_settings. @@ -2011,19 +1992,14 @@ def _apply_runtime( self._warm_llm_client = None if sandbox is not None: - # A sandbox handle (container_name + async ensure()). When the - # pipeline resolves a ``claude_code_cli`` client from the - # credential bundle, it wraps it in a ContainerCLIRunner so the - # agent CLI spawns inside the sandbox container — see - # ``_build_client_for``. Ignored for SDK providers (they never - # spawn the CLI). Bump the generation so reused states rebuild - # their client through the sandbox on the next turn. + # An XgenySandbox (``workdir`` + async ``ensure()``/``exec()``) — + # where this agent's code runs. Bump the generation so reused + # states pick it up on the next turn. self._attached_sandbox = sandbox self._client_generation += 1 self._warm_llm_client = None - # Also stamp it onto the Tool stage's context so the built-in - # fs/shell tools run inside the container on the SDK-provider path - # (the CLI path runs its own tools in-container already). + # Stamp it onto the Tool stage's context — that is the whole + # wiring: every built-in fs/shell tool reads ``ctx.sandbox``. self._set_tool_stage_sandbox(sandbox) if session_runtime is not None: @@ -3337,22 +3313,6 @@ def _build_client_for(self, provider: str) -> Any: if entry not in allow: allow.append(entry) kwargs["allow_tools"] = tuple(allow) - # Sandbox wrap: when a SandboxHandle is attached and this is the CLI - # provider, build the client so every spawn (and the --version probe) - # runs inside the sandbox container via ContainerCLIRunner. Reuses the - # exact kwargs resolved above (api_key, mcp_config, allow_tools, - # workspace_dir, ...) — the host never replicates them. SDK providers - # ignore the sandbox (they don't spawn a CLI). - if ( - provider == "claude_code_cli" - and self._attached_sandbox is not None - and self._containerize_cli - ): - from xgen_agent_runtime.llm_client.claude_code import ( - build_container_cli_client, - ) - - return build_container_cli_client(sandbox=self._attached_sandbox, **kwargs) return client_cls(**kwargs) async def _try_run_stage(self, order: int, current: Any, state: PipelineState) -> Any: diff --git a/src/xgen_agent_runtime/llm_client/__init__.py b/src/xgen_agent_runtime/llm_client/__init__.py index a9afd63..22bc3a0 100644 --- a/src/xgen_agent_runtime/llm_client/__init__.py +++ b/src/xgen_agent_runtime/llm_client/__init__.py @@ -8,14 +8,11 @@ from xgen_agent_runtime.llm_client._cli_runtime import ( CLIProcessRunner, - ContainerCLIRunner, - SandboxHandle, ) from xgen_agent_runtime.llm_client.anthropic import AnthropicClient from xgen_agent_runtime.llm_client.base import BaseClient, ClientCapabilities from xgen_agent_runtime.llm_client.claude_code import ( ClaudeCodeCLIClient, - build_container_cli_client, ) from xgen_agent_runtime.llm_client.credentials import ( ConfigError, @@ -50,13 +47,10 @@ "ClientCapabilities", "ClientRegistry", "ConfigError", - "ContainerCLIRunner", "ContentBlock", "CredentialBundle", "ProviderCredentials", "ProviderProfile", - "SandboxHandle", - "build_container_cli_client", "builtin_profiles", "probe_ollama_num_ctx", "resolve_local_context_window", diff --git a/src/xgen_agent_runtime/llm_client/_cli_runtime.py b/src/xgen_agent_runtime/llm_client/_cli_runtime.py index 377fb53..40e0946 100644 --- a/src/xgen_agent_runtime/llm_client/_cli_runtime.py +++ b/src/xgen_agent_runtime/llm_client/_cli_runtime.py @@ -55,9 +55,7 @@ Mapping, NamedTuple, Optional, - Protocol, Sequence, - runtime_checkable, ) logger = logging.getLogger(__name__) @@ -461,108 +459,6 @@ async def _kill_tree(self, proc: asyncio.subprocess.Process, *, force: bool = Fa # --------------------------------------------------------------------------- -@runtime_checkable -class SandboxHandle(Protocol): - """Minimal handle the :class:`ContainerCLIRunner` needs to target a - sandbox container. - - Any object exposing a ``container_name`` and an idempotent async - ``ensure()`` satisfies this — e.g. GAPT's ``WorkspaceSandbox``. The - executor deliberately knows nothing about *how* the container is created, - cloned, or mounted (that is the host platform's concern). It only needs - the running container's name and a way to make sure it is up before the - first spawn. - """ - - @property - def container_name(self) -> str: ... - - async def ensure(self) -> None: ... - - -@dataclass -class ContainerCLIRunner(CLIProcessRunner): - """``CLIProcessRunner`` that spawns the CLI *inside* a sandbox container. - - Generalises the ``SandboxedCLIProcessRunner`` that previously lived in - GAPT: only ``_spawn`` differs from the parent — argv becomes - - exec -i -w --env K=V ... - - so the agent only ever sees the container's ```` (a bind mount), - never the host filesystem. Everything else (timeout ladder, - SIGTERM→SIGKILL process-group teardown via the host-side ``exec``, stderr - collection, stream-json line buffering) is inherited unchanged: - ``start_new_session`` is preserved on POSIX so killing the host-side - ``exec`` group propagates to the CLI inside the container. - - The host needs the ``launcher`` (``docker`` by default) on PATH; it does - **not** need the agent binary — that lives in the container image. The - parent's host-binary existence check is therefore intentionally skipped. - """ - - sandbox: Optional[SandboxHandle] = None - #: Working directory *inside* the container (the bind-mounted project root). - workdir: str = "/workspace" - #: Host launcher that enters the container. ``docker`` by default; any - #: ``exec``-compatible CLI works (``podman`` etc.). - launcher: str = "docker" - #: The agent binary *inside* the container — always on PATH there (the - #: image installs it). The host-side ``binary`` field is ignored for the - #: actual spawn (it need not exist on the host). - container_binary: str = "claude" - - def __post_init__(self) -> None: - # Deliberately do NOT call super().__post_init__(): the parent validates - # that ``binary`` exists on the *host*, but for a container runner the - # agent binary lives in the image. We also do NOT eagerly check that the - # ``launcher`` exists — that is a runtime concern (a missing ``docker`` - # surfaces a clear error at ``exec`` time) and an eager check would - # couple construction to the host, breaking docker-less test/CI paths - # that intercept the spawn. Only the invariant the runner cannot work - # without — a sandbox — is enforced here. - if self.sandbox is None: - raise ValueError("ContainerCLIRunner requires sandbox=") - - async def _spawn(self, argv: Sequence[str]) -> tuple[asyncio.subprocess.Process, float]: - sandbox = self.sandbox - assert sandbox is not None # guaranteed by __post_init__ - # First spawn after a host restart may hit a stopped container. - # ensure() is idempotent; a failure here is non-fatal — the exec - # below surfaces the real error if the container truly isn't up. - try: - await sandbox.ensure() - except Exception: # pragma: no cover - defensive - logger.warning( - "container_cli_runner.ensure_failed container=%s", - getattr(sandbox, "container_name", "?"), - ) - - exec_argv: list[str] = ["exec", "-i", "-w", self.workdir] - for k, v in dict(self.env_extras or {}).items(): - exec_argv += ["--env", f"{k}={v}"] - # Inside the container the agent CLI is on PATH (the image installs - # it). We deliberately don't forward ``self.binary`` — a host path - # that need not exist in the container. - exec_argv += [sandbox.container_name, self.container_binary, *list(argv)] - - kwargs: dict[str, Any] = dict( - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - # The launcher needs the *host* env (PATH, DOCKER_HOST, ...). The - # child's env is what we passed via --env flags above; that is - # separate and already scoped. - env=os.environ.copy(), - cwd=None, - ) - if sys.platform != "win32": - kwargs["start_new_session"] = True - kwargs["limit"] = _cli_stream_limit() - proc = await asyncio.create_subprocess_exec(self.launcher, *exec_argv, **kwargs) - return proc, time.monotonic() - - # --------------------------------------------------------------------------- # Internal coroutine helpers # --------------------------------------------------------------------------- diff --git a/src/xgen_agent_runtime/llm_client/claude_code.py b/src/xgen_agent_runtime/llm_client/claude_code.py index 76f48c7..3878e57 100644 --- a/src/xgen_agent_runtime/llm_client/claude_code.py +++ b/src/xgen_agent_runtime/llm_client/claude_code.py @@ -49,8 +49,6 @@ CLIProtocolError, CLIResult, CLITimeout, - ContainerCLIRunner, - SandboxHandle, aiter_bytes, detect_binary, ) @@ -217,8 +215,8 @@ def __init__( Optional ``Callable[..., CLIProcessRunner]`` receiving ``binary=``, ``cwd=``, ``env_extras=``, ``timeout_s=``. The supported seam for hosts that wrap process spawning - (GAPT's docker sandbox) — absorbs the - ``CLIProcessRunner._spawn`` monkey-patch that pinned GAPT to + (a host-managed process sandbox) — absorbs the + ``CLIProcessRunner._spawn`` monkey-patch that pinned hosts to 2.1.0. The version-handshake probe routes through the same factory so the recorded version matches the binary that actually runs. @@ -808,52 +806,3 @@ async def create_message_stream( raise APIError( self._with_version(str(e)), category=ErrorCategory.CLI_PROTOCOL_ERROR ) from e - - -def build_container_cli_client( - *, - sandbox: SandboxHandle, - workdir: str = "/workspace", - launcher: str = "docker", - container_binary: str = "claude", - **client_kwargs: Any, -) -> "ClaudeCodeCLIClient": - """Build a :class:`ClaudeCodeCLIClient` whose every process spawn — the - per-request CLI run *and* the one-time ``--version`` handshake — happens - inside ``sandbox``'s container via :class:`ContainerCLIRunner`. - - This is the supported, host-agnostic way to run the agent CLI in a - sandbox; it absorbs the bespoke ``SandboxedCLIProcessRunner`` that hosts - (GAPT) previously had to carry. The host does **not** need the agent - binary installed — it lives in the container image — only the ``launcher`` - (``docker`` by default). - - ``client_kwargs`` are forwarded verbatim to :class:`ClaudeCodeCLIClient` - (``api_key``, ``auth_mode``, ``mcp_config``, ``allow_tools``, - ``workspace_dir``, ...). ``runner_factory`` must not be passed — it is set - here. - - Example:: - - client = build_container_cli_client( - sandbox=workspace_sandbox, # has .container_name + async .ensure() - api_key=api_key, - mcp_config=mcp_config, - ) - pipeline.attach_runtime(llm_client=client, hook_runner=hook_runner) - """ - if "runner_factory" in client_kwargs: - raise TypeError( - "build_container_cli_client sets runner_factory itself; do not pass it in client_kwargs" - ) - - def _factory(**runner_kwargs: Any) -> CLIProcessRunner: - return ContainerCLIRunner( - sandbox=sandbox, - workdir=workdir, - launcher=launcher, - container_binary=container_binary, - **runner_kwargs, - ) - - return ClaudeCodeCLIClient(**client_kwargs, runner_factory=_factory) diff --git a/src/xgen_agent_runtime/tools/__init__.py b/src/xgen_agent_runtime/tools/__init__.py index d1dabf7..c4bd86b 100644 --- a/src/xgen_agent_runtime/tools/__init__.py +++ b/src/xgen_agent_runtime/tools/__init__.py @@ -24,15 +24,18 @@ from xgen_agent_runtime.tools.scope import ToolScope, ToolScopeRule, ToolScopeManager from xgen_agent_runtime.tools.sandbox import ToolSandbox, SandboxConfig, SandboxPolicy -# Container-exec primitives (``docker exec`` into a SandboxHandle) — public so -# hosts can build sandboxed tools. ``_sandbox`` only depends on stdlib, so this -# import is cycle-safe (unlike importing the built_in package here). +# XGeny sandbox primitives — the session an agent's code runs in. Public so +# hosts can build sandboxed tools. ``_xgeny_sandbox`` only depends on stdlib, so +# this import is cycle-safe (unlike importing the built_in package here). # ``SandboxExecTool`` itself lives in ``xgen_agent_runtime.tools.built_in`` to keep # this module free of the built-in import cycle. -from xgen_agent_runtime.tools._sandbox import ( - SandboxExecError, - container_path, - sandbox_exec, +from xgen_agent_runtime.tools._xgeny_sandbox import ( + ExecResult, + SandboxError, + SandboxPathError, + XgenySandbox, + sandbox_path, + sandbox_root, sb_read_bytes, sb_run, sb_write_bytes, @@ -76,13 +79,16 @@ "ToolSandbox", "SandboxConfig", "SandboxPolicy", - # Container exec (docker exec into a SandboxHandle) — for sandboxed tools - "sandbox_exec", + # XGeny sandbox session — for sandboxed tools + "ExecResult", + "SandboxError", + "SandboxPathError", + "XgenySandbox", + "sandbox_path", + "sandbox_root", "sb_run", "sb_read_bytes", "sb_write_bytes", - "container_path", - "SandboxExecError", # Plugin discovery (entry-point group: xgen_agent_runtime.tools) "TOOL_ENTRY_POINT_GROUP", "ToolPluginRegistry", diff --git a/src/xgen_agent_runtime/tools/_sandbox.py b/src/xgen_agent_runtime/tools/_sandbox.py deleted file mode 100644 index 4024cea..0000000 --- a/src/xgen_agent_runtime/tools/_sandbox.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Run a tool's fs/shell primitive inside a sandbox container (``docker exec``). - -Shared by the built-in fs/shell tools so an SDK-provider agent -(anthropic/openai/google/vllm) gets the same container isolation the -``claude_code_cli`` path already has: when ``ToolContext.sandbox`` is set, the -tool routes its I/O here instead of touching the host filesystem. - -The ``sandbox`` is any object with ``container_name: str`` + async -``ensure()`` (the executor's ``SandboxHandle`` Protocol; GAPT's -``WorkspaceSandbox`` and Geny's ``GaptSandboxHandle`` both satisfy it). -""" - -from __future__ import annotations - -import asyncio -import os -import posixpath -import sys -from typing import Any, Mapping, Optional, Sequence, Tuple - - -class SandboxExecError(RuntimeError): - """A ``docker exec`` into the sandbox failed to spawn.""" - - -# ── Host ↔ container path translation (workspace-unified sandboxes) ──── -# -# A sandbox whose /workspace bind-mounts the session's HOST workspace can -# advertise the mapping on the handle (both optional, duck-typed): -# -# * ``container_workdir: str`` — the in-container mount root -# (conventionally "/workspace"). -# * ``map_path(host_path) -> str | None`` — absolute host path → -# absolute container path (None = not under the mount). -# -# Legacy handles without these attributes keep the exact old behaviour. -# The guard in :func:`resolve_container_workdir` also fixes the classic -# failure mode where a HOST-absolute working_dir was passed verbatim to -# ``docker exec -w`` and chdir-killed every call: an unmappable host -# workdir now degrades to the container root instead of a dead exec. - - -def resolve_container_workdir(sandbox: Any, workdir: Optional[str]) -> str: - """The ``docker exec -w`` value for this call — always container-side.""" - default = getattr(sandbox, "container_workdir", None) or "/workspace" - if not workdir: - return default - mapper = getattr(sandbox, "map_path", None) - if callable(mapper): - try: - mapped = mapper(workdir) - except Exception: # noqa: BLE001 — mapping must never break an exec - mapped = None - if mapped: - return str(mapped) - root = "/" + default.strip("/") - if workdir == root or workdir.startswith(root + "/"): - return workdir # already container-side - # Host path with no mapping — exec at the container root rather than - # chdir-failing into a directory that does not exist in the container. - return default - - -def map_into_container(sandbox: Any, file_path: str, workdir: Optional[str]) -> str: - """Resolve a tool-supplied path (relative OR host-absolute) to an - absolute in-container path, honouring the handle's mapping.""" - p = file_path or "." - mapper = getattr(sandbox, "map_path", None) - if callable(mapper) and posixpath.isabs(p): - try: - mapped = mapper(p) - except Exception: # noqa: BLE001 - mapped = None - if mapped: - return str(mapped) - return container_path(p, resolve_container_workdir(sandbox, workdir)) - - -def container_path(file_path: str, workdir: str) -> str: - """Resolve ``file_path`` to an absolute path *inside* the container, - rooted at ``workdir`` (default ``/workspace``), refusing to escape it. - - Relative paths join onto ``workdir``; absolute paths are taken as-is but - must stay within ``workdir``. Raises ``PermissionError`` on escape.""" - base = workdir or "/workspace" - base = "/" + base.strip("/") if base != "/" else "/" - raw = file_path or "." - joined = raw if posixpath.isabs(raw) else posixpath.join(base, raw) - norm = posixpath.normpath(joined) - if norm != base and not norm.startswith(base.rstrip("/") + "/"): - raise PermissionError(f"path {file_path!r} resolves outside the sandbox workdir {base!r}") - return norm - - -async def sandbox_exec( - sandbox: Any, - argv: Sequence[str], - *, - cwd: str = "/workspace", - input_bytes: Optional[bytes] = None, - env: Optional[Mapping[str, str]] = None, - timeout_s: float = 120.0, - launcher: str = "docker", -) -> Tuple[int, bytes, bytes]: - """`` exec -i -w --env … ``. - - Returns ``(returncode, stdout, stderr)``. Brings the container live first - via ``sandbox.ensure()`` (idempotent). Raises :class:`SandboxExecError` if - the launcher cannot be spawned, :class:`asyncio.TimeoutError` on timeout.""" - try: - await sandbox.ensure() - except Exception: # pragma: no cover - defensive; exec surfaces real errors - pass - - cwd = resolve_container_workdir(sandbox, cwd) - exec_argv = ["exec", "-i", "-w", cwd] - # Optional handle protocol: run exec'ed commands as a specific user. - # Bind-mounted session workspaces are owned by the HOST service user - # (typically root); the container's default user (e.g. ubuntu:1000) - # gets EACCES on every write — "-u 0:0" aligns the writers. - exec_user = getattr(sandbox, "exec_user", None) - if exec_user: - exec_argv += ["-u", str(exec_user)] - for k, v in dict(env or {}).items(): - exec_argv += ["--env", f"{k}={v}"] - exec_argv += [sandbox.container_name, *list(argv)] - - kwargs: dict[str, Any] = dict( - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=os.environ.copy(), # the launcher needs host PATH/DOCKER_HOST - ) - if sys.platform != "win32": - kwargs["start_new_session"] = True - try: - proc = await asyncio.create_subprocess_exec(launcher, *exec_argv, **kwargs) - except OSError as e: - raise SandboxExecError(f"failed to spawn {launcher!r}: {e}") from e - - try: - out, err = await asyncio.wait_for(proc.communicate(input=input_bytes), timeout=timeout_s) - except asyncio.TimeoutError: - try: - proc.kill() - await proc.wait() - except Exception: - pass - raise - return (proc.returncode if proc.returncode is not None else -1), out, err - - -async def sb_read_bytes(sandbox: Any, path: str, *, workdir: str) -> bytes: - """Read a file inside the container. Raises FileNotFoundError / OSError.""" - cpath = map_into_container(sandbox, path, workdir) - rc, out, err = await sandbox_exec(sandbox, ["cat", "--", cpath], cwd=workdir) - if rc != 0: - msg = err.decode("utf-8", "replace").strip() - if "no such file" in msg.lower(): - raise FileNotFoundError(path) - raise OSError(msg or f"cat exited {rc}") - return out - - -async def sb_write_bytes(sandbox: Any, path: str, data: bytes, *, workdir: str) -> int: - """Create/overwrite a file inside the container (parent dirs created). - Returns bytes written.""" - cpath = map_into_container(sandbox, path, workdir) - # mkdir -p the parent, then write stdin to the file. Single sh -c so the - # path is quoted once; data flows over stdin (binary-safe, no escaping). - script = 'mkdir -p "$(dirname "$1")" && cat > "$1"' - rc, _out, err = await sandbox_exec( - sandbox, - ["sh", "-c", script, "sh", cpath], - cwd=workdir, - input_bytes=data, - ) - if rc != 0: - raise OSError(err.decode("utf-8", "replace").strip() or f"write exited {rc}") - return len(data) - - -async def sb_run( - sandbox: Any, - command: str, - *, - workdir: str, - env: Optional[Mapping[str, str]] = None, - timeout_s: float = 120.0, -) -> Tuple[int, str, str]: - """Run a shell command inside the container; returns (rc, stdout, stderr).""" - rc, out, err = await sandbox_exec( - sandbox, - ["bash", "-lc", command], - cwd=workdir, - env=env, - timeout_s=timeout_s, - ) - return rc, out.decode("utf-8", "replace"), err.decode("utf-8", "replace") diff --git a/src/xgen_agent_runtime/tools/_xgeny_sandbox.py b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py new file mode 100644 index 0000000..04600a8 --- /dev/null +++ b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py @@ -0,0 +1,175 @@ +"""XGeny 도구가 코드를 실행하는 곳 — ``xgen-workflow-sandbox`` 세션. + +**컨테이너가 아니다.** ``docker`` 도, ``container_name`` 도, 호스트↔컨테이너 +경로 변환도 없다. 에이전트를 태우는 서비스와 코드를 돌리는 서비스가 같은 절대 +경로를 쓰기 때문에 (호스트가 두 루트를 같은 문자열로 맞춘다) **변환할 좌표계가 +애초에 하나뿐이다.** + +런타임은 :class:`XgenySandbox` 프로토콜만 안다. 그 뒤가 HTTP 인지 인프로세스인지 +로컬 디렉터리인지는 호스트가 정한다 — 그래서 이 모듈은 stdlib 밖을 import 하지 +않고, 테스트는 가짜 구현 하나로 파일/셸 도구 전부를 검증할 수 있다. + +파일 읽기·쓰기가 :meth:`~XgenySandbox.read_bytes` / :meth:`write_bytes` 라는 +**1급 연산**인 것이 중요하다. 이걸 셸 명령(``cat``, ``sh -c 'cat > …'``)으로 +흉내내면 파일 하나 읽는 데 프로세스가 하나 뜨고, 실패가 "명령 실패"로 뭉개져 +"파일이 없다"와 "권한이 없다"를 구분할 수 없게 된다. +""" + +from __future__ import annotations + +import posixpath +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Protocol, Sequence, Tuple, runtime_checkable + +__all__ = [ + "ExecResult", + "SandboxError", + "SandboxPathError", + "XgenySandbox", + "sandbox_path", + "sandbox_root", + "sb_read_bytes", + "sb_run", + "sb_write_bytes", +] + + +class SandboxError(RuntimeError): + """샌드박스에 닿을 수 없거나 요청을 수행하지 못했다.""" + + +class SandboxPathError(SandboxError): + """세션 루트 밖을 가리키는 경로. + + 가드가 여기 있는 이유: 도구마다 각자 막으면 새로 추가되는 도구가 매번 + 빠뜨린다. 샌드박스로 나가는 모든 경로는 :func:`sandbox_path` 를 지난다. + """ + + +@dataclass(frozen=True) +class ExecResult: + """명령 한 번의 결과. 바이트 그대로 — 디코딩은 부르는 쪽 몫이다.""" + + rc: int + stdout: bytes + stderr: bytes + + @property + def ok(self) -> bool: + return self.rc == 0 + + +@runtime_checkable +class XgenySandbox(Protocol): + """에이전트 하나의 실행 세션. + + 구현체는 :mod:`editor.geny_bridge.sandbox_mount`(xgen-workflow) 의 HTTP + 클라이언트다. 테스트는 같은 모양의 로컬 구현을 쓴다. + """ + + #: 세션의 작업 루트 — **절대 경로**. 이 밖으로는 나갈 수 없다. + workdir: str + + async def ensure(self) -> None: + """세션을 살아 있게 만든다. 멱등 — 몇 번 불러도 같다.""" + ... + + async def exec( + self, + argv: Sequence[str], + *, + cwd: Optional[str] = None, + stdin: Optional[bytes] = None, + env: Optional[Mapping[str, str]] = None, + timeout_s: float = 120.0, + ) -> ExecResult: + ... + + async def read_bytes(self, path: str) -> bytes: + """없으면 :class:`FileNotFoundError`.""" + ... + + async def write_bytes(self, path: str, data: bytes) -> int: + """상위 디렉터리는 알아서 만든다. 쓴 바이트 수를 돌려준다.""" + ... + + +# ── 경로 ────────────────────────────────────────────────────────────── + + +def sandbox_root(sandbox: Any) -> str: + root = str(getattr(sandbox, "workdir", "") or "/workspace") + return "/" + root.strip("/") if root != "/" else "/" + + +def sandbox_path(sandbox: Any, path: str, workdir: str = "") -> str: + """도구가 준 경로 → 세션 안의 절대 경로. + + 상대 경로는 ``workdir``(없으면 세션 루트) 기준으로 푼다. 결과가 루트 밖이면 + :class:`SandboxPathError` — ``..`` 나 절대경로로 세션을 빠져나가는 것을 + 여기서 한 번에 막는다. + + ``workdir`` 은 보통 ``ToolContext.working_dir`` 이다. 호스트가 양쪽 루트를 + 같은 문자열로 맞추므로 그 값은 세션 안에서도 그대로 유효하다 — 이것이 + 변환 함수를 두지 않는 이유다. + """ + root = sandbox_root(sandbox) + base = str(workdir or "").strip() or root + if not posixpath.isabs(base): + base = posixpath.join(root, base) + target = str(path or ".") + if not posixpath.isabs(target): + target = posixpath.join(base, target) + resolved = posixpath.normpath(target) + if resolved != root and not resolved.startswith(root.rstrip("/") + "/"): + raise SandboxPathError( + f"경로가 샌드박스 세션 밖을 가리킵니다: {path!r} → {resolved!r} (루트 {root!r})" + ) + return resolved + + +def _cwd(sandbox: Any, workdir: str) -> str: + """``exec`` 에 넘길 작업 디렉터리 — 항상 세션 안.""" + try: + return sandbox_path(sandbox, ".", workdir) + except SandboxPathError: + # 세션과 무관한 workdir 이 들어왔다. chdir 실패로 모든 호출을 죽이느니 + # 루트에서 실행한다 (GAPT 시절 host-absolute workdir 이 exec 를 통째로 + # 죽였던 실패 모드를 되풀이하지 않는다). + return sandbox_root(sandbox) + + +# ── 도구가 쓰는 3가지 ────────────────────────────────────────────────── + + +async def sb_run( + sandbox: Any, + command: str, + *, + workdir: str = "", + env: Optional[Mapping[str, str]] = None, + timeout_s: float = 120.0, +) -> Tuple[int, str, str]: + """셸 명령 하나. ``(rc, stdout, stderr)`` — 문자열로 디코딩해서 준다.""" + await sandbox.ensure() + result = await sandbox.exec( + ["bash", "-lc", command], + cwd=_cwd(sandbox, workdir), + env=env, + timeout_s=timeout_s, + ) + return ( + result.rc, + result.stdout.decode("utf-8", "replace"), + result.stderr.decode("utf-8", "replace"), + ) + + +async def sb_read_bytes(sandbox: Any, path: str, *, workdir: str = "") -> bytes: + await sandbox.ensure() + return await sandbox.read_bytes(sandbox_path(sandbox, path, workdir)) + + +async def sb_write_bytes(sandbox: Any, path: str, data: bytes, *, workdir: str = "") -> int: + await sandbox.ensure() + return await sandbox.write_bytes(sandbox_path(sandbox, path, workdir), data) diff --git a/src/xgen_agent_runtime/tools/base.py b/src/xgen_agent_runtime/tools/base.py index 34cace2..44349ef 100644 --- a/src/xgen_agent_runtime/tools/base.py +++ b/src/xgen_agent_runtime/tools/base.py @@ -153,12 +153,12 @@ class ToolContext: # behaves exactly as it did pre-Phase-7. Typed as ``Any`` to avoid # a hard import dependency on the permission subsystem. permission_rules: List[Any] = field(default_factory=list) - # Sandbox handle (``container_name`` + async ``ensure()``). When set, the - # built-in fs/shell tools (bash/read/write/edit/grep/glob/ls) run their I/O - # *inside* the container (``docker exec``) instead of on the host — so an - # SDK-provider agent (anthropic/openai/…) is sandboxed the same way the - # claude_code_cli path already is. ``None`` (default) = host execution, - # unchanged. Typed ``Any`` to avoid importing the llm_client SandboxHandle. + # The agent's XGeny sandbox session (``xgen_agent_runtime.tools. + # _xgeny_sandbox.XgenySandbox``: ``workdir`` + async ``ensure``/``exec``/ + # ``read_bytes``/``write_bytes``). When set, the built-in fs/shell tools + # (bash/read/write/edit/grep/glob/ls) do their I/O *there* instead of on + # this host. ``None`` (default) = host execution. Typed ``Any`` so this + # module stays free of the protocol import. sandbox: Optional[Any] = None # Self-modifying environment: the live PipelineEnvironment controller for # this session. The built-in ``env_*`` tools read it to view/edit the diff --git a/src/xgen_agent_runtime/tools/built_in/bash_tool.py b/src/xgen_agent_runtime/tools/built_in/bash_tool.py index 337ba35..3404821 100644 --- a/src/xgen_agent_runtime/tools/built_in/bash_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/bash_tool.py @@ -97,10 +97,10 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu timeout_ms = min(input.get("timeout", _DEFAULT_TIMEOUT_MS), _MAX_TIMEOUT_MS) timeout_s = timeout_ms / 1000.0 - # Sandbox: run the command inside the container (docker exec) instead + # Sandbox: run the command in the agent's XGeny session instead # of on the host. Same output shaping as the host path below. if context.sandbox is not None: - from xgen_agent_runtime.tools._sandbox import sb_run + from xgen_agent_runtime.tools._xgeny_sandbox import sb_run try: exit_code, stdout, stderr = await sb_run( diff --git a/src/xgen_agent_runtime/tools/built_in/edit_tool.py b/src/xgen_agent_runtime/tools/built_in/edit_tool.py index ba59764..abb0156 100644 --- a/src/xgen_agent_runtime/tools/built_in/edit_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/edit_tool.py @@ -65,7 +65,7 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu # Sandbox: read-modify-write the file inside the container. if context.sandbox is not None: - from xgen_agent_runtime.tools._sandbox import sb_read_bytes, sb_write_bytes + from xgen_agent_runtime.tools._xgeny_sandbox import sb_read_bytes, sb_write_bytes wd = context.working_dir or "/workspace" try: diff --git a/src/xgen_agent_runtime/tools/built_in/glob_tool.py b/src/xgen_agent_runtime/tools/built_in/glob_tool.py index d182c47..6fa39dc 100644 --- a/src/xgen_agent_runtime/tools/built_in/glob_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/glob_tool.py @@ -64,7 +64,7 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu if context.sandbox is not None: import shlex - from xgen_agent_runtime.tools._sandbox import sb_run + from xgen_agent_runtime.tools._xgeny_sandbox import sb_run wd = context.working_dir or "/workspace" spath = input.get("path", "") or "." diff --git a/src/xgen_agent_runtime/tools/built_in/grep_tool.py b/src/xgen_agent_runtime/tools/built_in/grep_tool.py index c5d0f2a..98eade8 100644 --- a/src/xgen_agent_runtime/tools/built_in/grep_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/grep_tool.py @@ -96,7 +96,7 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu if context.sandbox is not None: import shlex - from xgen_agent_runtime.tools._sandbox import sb_run + from xgen_agent_runtime.tools._xgeny_sandbox import sb_run wd = context.working_dir or "/workspace" spath = input.get("path", "") or "." diff --git a/src/xgen_agent_runtime/tools/built_in/read_tool.py b/src/xgen_agent_runtime/tools/built_in/read_tool.py index 271b6cd..c918f7e 100644 --- a/src/xgen_agent_runtime/tools/built_in/read_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/read_tool.py @@ -65,9 +65,9 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu offset = input.get("offset", 0) limit = input.get("limit", _DEFAULT_LIMIT) - # Sandbox: read the file inside the container (docker exec cat). + # Sandbox: read the file from the agent's XGeny session. if context.sandbox is not None: - from xgen_agent_runtime.tools._sandbox import sb_read_bytes + from xgen_agent_runtime.tools._xgeny_sandbox import sb_read_bytes wd = context.working_dir or "/workspace" try: diff --git a/src/xgen_agent_runtime/tools/built_in/sandbox_exec_tool.py b/src/xgen_agent_runtime/tools/built_in/sandbox_exec_tool.py index 760e4f2..1eb8981 100644 --- a/src/xgen_agent_runtime/tools/built_in/sandbox_exec_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/sandbox_exec_tool.py @@ -1,8 +1,8 @@ -"""SandboxExecTool — a tool whose implementation is *code that runs inside a -sandbox container* (``docker exec``). +"""SandboxExecTool — a tool whose implementation is *code that runs inside the +agent's XGeny sandbox session*. This is the execution core of **Sandbox Tool Packs**: an agent authors a script -in an isolated GAPT workspace, and this tool dispatches the tool's input into +in its isolated sandbox workspace, and this tool dispatches the tool's input into that script — inside the sandbox, never on the host — and returns its output. Invocation contract (tool ↔ script), language-agnostic + shell-testable: @@ -13,8 +13,8 @@ * a non-zero exit code (or anything on stderr with a non-zero exit) → ``ToolResult(is_error=True)``. -The tool carries a :class:`SandboxHandle` (``container_name`` + async -``ensure()``). It has **no host fallback** — without a sandbox it errors, by +The tool carries an :class:`XgenySandbox` (``workdir`` + async ``ensure()`` ++ ``exec()``). It has **no host fallback** — without a sandbox it errors, by design: a sandboxed tool's whole point is isolation. The spec (everything except the live sandbox handle) is serializable via @@ -28,7 +28,7 @@ import json from typing import Any, Dict, Optional, Sequence -from xgen_agent_runtime.tools._sandbox import sandbox_exec +from xgen_agent_runtime.tools._xgeny_sandbox import sandbox_path from xgen_agent_runtime.tools.base import Tool, ToolCapabilities, ToolContext, ToolResult _NO_SANDBOX = ( @@ -42,7 +42,7 @@ class SandboxExecTool(Tool): - """A Tool that runs an authored script inside a sandbox via ``docker exec``.""" + """A Tool that runs an authored script inside the agent's XGeny sandbox session.""" def __init__( self, @@ -109,13 +109,14 @@ async def execute( argv = [self._runtime, self._entrypoint, *self._argv] payload = json.dumps(input or {}, ensure_ascii=False).encode("utf-8") try: - rc, out, err = await sandbox_exec( - sandbox, + await sandbox.ensure() + result = await sandbox.exec( argv, - cwd=self._workdir, - input_bytes=payload, + cwd=sandbox_path(sandbox, ".", self._workdir), + stdin=payload, timeout_s=self._timeout_s, ) + rc, out, err = result.rc, result.stdout, result.stderr except asyncio.TimeoutError: return ToolResult( content=f"sandboxed tool '{self._name}' timed out after {self._timeout_s:g}s", diff --git a/src/xgen_agent_runtime/tools/built_in/workspace_tools.py b/src/xgen_agent_runtime/tools/built_in/workspace_tools.py index fa71383..970b706 100644 --- a/src/xgen_agent_runtime/tools/built_in/workspace_tools.py +++ b/src/xgen_agent_runtime/tools/built_in/workspace_tools.py @@ -19,7 +19,7 @@ * ``SandboxPut`` — copy a file: files workspace → sandbox * ``SandboxFetch`` — copy a file: sandbox → files workspace -Transfers stream through the existing ``_sandbox`` primitives (docker exec, +Transfers stream through the ``_xgeny_sandbox`` primitives (session I/O, binary-safe) and are path-guarded to the session storage on the host side. """ @@ -196,7 +196,7 @@ async def execute(self, input, context): } ) workdir = (input.get("workdir") or DEFAULT_SANDBOX_WORKDIR).strip() - from xgen_agent_runtime.tools._sandbox import sb_run + from xgen_agent_runtime.tools._xgeny_sandbox import sb_run try: rc, out, err = await sb_run(sandbox, "ls -la", workdir=workdir, timeout_s=20) @@ -277,7 +277,7 @@ async def execute(self, input, context): workdir = (input.get("workdir") or DEFAULT_SANDBOX_WORKDIR).strip() dest = (input.get("dest") or src.name).strip() - from xgen_agent_runtime.tools._sandbox import container_path, sb_write_bytes + from xgen_agent_runtime.tools._xgeny_sandbox import sandbox_path, sb_write_bytes try: written = await sb_write_bytes(sandbox, dest, src.read_bytes(), workdir=workdir) @@ -287,7 +287,7 @@ async def execute(self, input, context): content={ "copied": True, "source": src.relative_to(root).as_posix(), - "sandbox_path": container_path(dest, workdir), + "sandbox_path": sandbox_path(sandbox, dest, workdir), "bytes": written, } ) @@ -341,7 +341,7 @@ async def execute(self, input, context): workdir = (input.get("workdir") or DEFAULT_SANDBOX_WORKDIR).strip() source = input["source"] - from xgen_agent_runtime.tools._sandbox import sb_read_bytes + from xgen_agent_runtime.tools._xgeny_sandbox import sb_read_bytes try: data = await sb_read_bytes(sandbox, source, workdir=workdir) diff --git a/src/xgen_agent_runtime/tools/built_in/write_tool.py b/src/xgen_agent_runtime/tools/built_in/write_tool.py index 893a126..93e1fee 100644 --- a/src/xgen_agent_runtime/tools/built_in/write_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/write_tool.py @@ -46,9 +46,9 @@ async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResu file_path = input.get("file_path", "") content = input.get("content", "") - # Sandbox: write the file inside the container (docker exec). + # Sandbox: write the file into the agent's XGeny session. if context.sandbox is not None: - from xgen_agent_runtime.tools._sandbox import sb_write_bytes + from xgen_agent_runtime.tools._xgeny_sandbox import sb_write_bytes wd = context.working_dir or "/workspace" try: diff --git a/tests/llm_client/unit/test_container_runner.py b/tests/llm_client/unit/test_container_runner.py deleted file mode 100644 index cb1d659..0000000 --- a/tests/llm_client/unit/test_container_runner.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for the container sandbox runner (L1 sandbox-execution primitive). - -``ContainerCLIRunner`` generalises GAPT's former ``SandboxedCLIProcessRunner``: -it runs the agent CLI inside a sandbox container via `` exec``. These -tests are host-independent — they never require ``docker`` or ``claude`` to be -installed (the launcher check uses ``sh``, and the spawn is intercepted). -""" - -from __future__ import annotations - -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src")) - -import pytest - -import xgen_agent_runtime.llm_client._cli_runtime as rt -from xgen_agent_runtime.llm_client import ( - ClaudeCodeCLIClient, - ContainerCLIRunner, - SandboxHandle, - build_container_cli_client, -) -from xgen_agent_runtime.llm_client._cli_runtime import CLIBinaryNotFound - - -class FakeSandbox: - """Satisfies the :class:`SandboxHandle` Protocol.""" - - def __init__(self, name: str) -> None: - self.container_name = name - self.ensured = False - - async def ensure(self) -> None: - self.ensured = True - - -def test_fake_sandbox_satisfies_protocol() -> None: - assert isinstance(FakeSandbox("c"), SandboxHandle) - - -def test_requires_sandbox() -> None: - with pytest.raises(ValueError): - ContainerCLIRunner(binary="", sandbox=None, launcher="docker") - - -def test_constructs_without_host_binary_or_launcher() -> None: - # The agent binary lives in the container, and the launcher is a runtime - # concern — neither must trip construction (docker-less test/CI must work). - runner = ContainerCLIRunner(binary="", sandbox=FakeSandbox("c"), launcher="docker") - assert runner.workdir == "/workspace" - assert runner.container_binary == "claude" - - -@pytest.mark.asyncio -async def test_spawn_builds_exec_argv(monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict = {} - - class _Proc: - returncode = 0 - - async def fake_exec(*args, **kwargs): - captured["args"] = args - captured["kwargs"] = kwargs - return _Proc() - - monkeypatch.setattr(rt.asyncio, "create_subprocess_exec", fake_exec) - - sandbox = FakeSandbox("gapt-ws-abc") - runner = ContainerCLIRunner( - binary="", - sandbox=sandbox, - launcher="sh", - env_extras={"ANTHROPIC_API_KEY": "sek"}, - ) - await runner._spawn(["-p", "hi"]) - - # ensure() ran before the spawn. - assert sandbox.ensured is True - - args = list(captured["args"]) - assert args[0] == "sh" # launcher first - rest = args[1:] - assert rest[:4] == ["exec", "-i", "-w", "/workspace"] - assert "--env" in rest - assert "ANTHROPIC_API_KEY=sek" in rest - # container name, then in-container binary, then the agent argv. - ci = rest.index("gapt-ws-abc") - assert rest[ci + 1] == "claude" - assert rest[ci + 2 :] == ["-p", "hi"] - # The launcher needs host env; the child env is via --env flags. - assert captured["kwargs"]["cwd"] is None - - -@pytest.mark.asyncio -async def test_custom_workdir_and_binary(monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict = {} - - class _Proc: - returncode = 0 - - async def fake_exec(*args, **kwargs): - captured["args"] = args - return _Proc() - - monkeypatch.setattr(rt.asyncio, "create_subprocess_exec", fake_exec) - - runner = ContainerCLIRunner( - binary="", - sandbox=FakeSandbox("box"), - launcher="sh", - workdir="/srv/app", - container_binary="codex", - ) - await runner._spawn(["--version"]) - rest = list(captured["args"])[1:] - assert rest[:4] == ["exec", "-i", "-w", "/srv/app"] - ci = rest.index("box") - assert rest[ci + 1] == "codex" - - -def test_build_container_cli_client_sets_factory() -> None: - sandbox = FakeSandbox("gapt-ws-1") - client = build_container_cli_client(sandbox=sandbox, launcher="sh", api_key="k") - runner = client._make_runner() - assert isinstance(runner, ContainerCLIRunner) - assert runner.sandbox is sandbox - assert runner.launcher == "sh" - - -def test_build_rejects_runner_factory() -> None: - with pytest.raises(TypeError): - build_container_cli_client( - sandbox=FakeSandbox("c"), - launcher="sh", - runner_factory=lambda **k: None, # type: ignore[arg-type] - ) - - -def test_make_runner_without_factory_still_requires_host_binary() -> None: - # Backward-compat: the default in-process runner still needs a real host - # binary; only the factory path is exempt. - client = ClaudeCodeCLIClient(api_key="k", binary_path="/nonexistent/claude-xyz") - with pytest.raises(CLIBinaryNotFound): - client._make_runner() diff --git a/tests/unit/test_sandbox_attach.py b/tests/unit/test_sandbox_attach.py index 66def72..7a4e8c6 100644 --- a/tests/unit/test_sandbox_attach.py +++ b/tests/unit/test_sandbox_attach.py @@ -1,6 +1,10 @@ -"""attach_runtime(sandbox=) wraps a resolved claude_code_cli client in a -ContainerCLIRunner, reusing the host's resolved client kwargs. SDK providers -ignore the sandbox. +"""``attach_runtime(sandbox=)`` 는 **도구 실행지**를 붙이는 것이지 LLM 클라이언트를 +바꾸는 것이 아니다. + +예전에는 샌드박스를 붙이면 claude_code_cli 클라이언트가 컨테이너 러너로 +감싸졌다(GAPT). 그 결합을 없앴다 — 어떤 프로바이더든 클라이언트는 여기서 돌고, +샌드박스에는 **도구를 통해서만** 닿는다. 프로바이더마다 격리 방식이 달라지면 +"이 백엔드에서만 되는 도구"가 생긴다. """ from __future__ import annotations @@ -12,96 +16,56 @@ from xgen_agent_runtime.core.pipeline import Pipeline from xgen_agent_runtime.llm_client import CredentialBundle, ProviderCredentials -from xgen_agent_runtime.llm_client._cli_runtime import ContainerCLIRunner class _FakeSandbox: - container_name = "gapt-ws-abc" + workdir = "/xgeny/workspace/workflow/w1/workspace" async def ensure(self) -> None: # pragma: no cover - not spawned here return None -def test_attach_runtime_stores_sandbox() -> None: - p = Pipeline() - assert p._attached_sandbox is None - p.attach_runtime(sandbox=_FakeSandbox()) - assert isinstance(p._attached_sandbox, _FakeSandbox) - - -def test_build_client_for_wraps_cli_in_container_runner() -> None: +def _cli_pipeline() -> Pipeline: p = Pipeline() p._credentials = CredentialBundle( by_provider={ - "claude_code_cli": ProviderCredentials(api_key="sk-test") + "claude_code_cli": ProviderCredentials(api_key="sk-test", binary_path="/bin/sh") } ) - p._attached_sandbox = _FakeSandbox() + return p - client = p._build_client_for("claude_code_cli") - assert client.provider == "claude_code_cli" - # Every spawn (incl. the --version probe) routes through the container. - runner = client._make_runner() - assert isinstance(runner, ContainerCLIRunner) - assert runner.sandbox.container_name == "gapt-ws-abc" - # The API key resolved by the host flows into the container env. - assert runner.env_extras.get("ANTHROPIC_API_KEY") == "sk-test" - -def test_build_client_for_sdk_provider_ignores_sandbox() -> None: +def test_attach_runtime_stores_the_sandbox() -> None: p = Pipeline() - p._credentials = CredentialBundle( - by_provider={ - "anthropic": ProviderCredentials(api_key="sk-test") - } - ) - p._attached_sandbox = _FakeSandbox() - - client = p._build_client_for("anthropic") - # Not a CLI client → not wrapped; sandbox is irrelevant for SDK providers. - assert type(client).__name__ == "AnthropicClient" + assert p._attached_sandbox is None + p.attach_runtime(sandbox=_FakeSandbox()) + assert isinstance(p._attached_sandbox, _FakeSandbox) -def test_no_sandbox_builds_plain_cli_client() -> None: +def test_the_tool_stage_context_gets_it() -> None: + """도구가 보는 것은 ``ctx.sandbox`` 하나다 — 배선의 전부.""" p = Pipeline() - p._credentials = CredentialBundle( - by_provider={ - "claude_code_cli": ProviderCredentials(api_key="sk-test", binary_path="/bin/sh") - } - ) - # No sandbox attached → default in-process runner path. - client = p._build_client_for("claude_code_cli") - runner = client._make_runner() - assert not isinstance(runner, ContainerCLIRunner) + p.attach_runtime(sandbox=_FakeSandbox()) + ctx = getattr(p, "_tool_context", None) or getattr(p, "_attached_tool_context", None) + if ctx is not None: # 스테이지가 없으면 attach 는 무음 no-op (러너 규약) + assert getattr(ctx, "sandbox", None) is not None -def test_containerize_cli_false_keeps_cli_on_host_but_attaches_sandbox() -> None: - """Decouple: a sandbox attached with containerize_cli=False is used for TOOL - execution (ctx.sandbox) but the claude_code_cli client stays on the host — - so an OAuth (rotating-token) session can use sandboxed GAPT/forge tools - without the in-container OAuth rotation problem.""" - p = Pipeline() - p._credentials = CredentialBundle( - by_provider={ - "claude_code_cli": ProviderCredentials(api_key="sk-test", binary_path="/bin/sh") - } - ) - p.attach_runtime(sandbox=_FakeSandbox(), containerize_cli=False) - # Sandbox is attached (tools get ctx.sandbox)… - assert isinstance(p._attached_sandbox, _FakeSandbox) - assert p._containerize_cli is False - # …but the CLI client is NOT wrapped in a container runner. - client = p._build_client_for("claude_code_cli") - runner = client._make_runner() - assert not isinstance(runner, ContainerCLIRunner) +def test_the_cli_client_is_not_wrapped() -> None: + """CLI 는 여기서 돈다. 샌드박스가 붙어도 스폰 경로는 그대로다.""" + p = _cli_pipeline() + plain = p._build_client_for("claude_code_cli")._make_runner() + p.attach_runtime(sandbox=_FakeSandbox()) + wrapped = p._build_client_for("claude_code_cli")._make_runner() + + assert type(wrapped) is type(plain), "샌드박스가 클라이언트 스폰 방식을 바꿨다" -def test_containerize_cli_default_true_still_wraps() -> None: + +def test_sdk_providers_are_unaffected() -> None: p = Pipeline() p._credentials = CredentialBundle( - by_provider={"claude_code_cli": ProviderCredentials(api_key="sk-test")} + by_provider={"anthropic": ProviderCredentials(api_key="sk-test")} ) - p.attach_runtime(sandbox=_FakeSandbox()) # default containerize_cli=True - assert p._containerize_cli is True - runner = p._build_client_for("claude_code_cli")._make_runner() - assert isinstance(runner, ContainerCLIRunner) + p._attached_sandbox = _FakeSandbox() + assert type(p._build_client_for("anthropic")).__name__ == "AnthropicClient" diff --git a/tests/unit/test_sandbox_exec_tool.py b/tests/unit/test_sandbox_exec_tool.py index 0b6a21c..44f108c 100644 --- a/tests/unit/test_sandbox_exec_tool.py +++ b/tests/unit/test_sandbox_exec_tool.py @@ -1,8 +1,8 @@ -"""SandboxExecTool — runs an authored script inside a sandbox (docker exec). +"""SandboxExecTool — 에이전트가 작성한 스크립트를 자기 샌드박스 세션에서 돌린다. -The sandbox is faked: a SandboxHandle whose ``container_name``/``ensure`` are -inert, and we monkeypatch ``sandbox_exec`` to simulate the container without -needing Docker. This exercises the tool↔script JSON contract + error paths. +샌드박스는 가짜다 — :class:`XgenySandbox` 모양을 그대로 만족하는 객체 하나. +monkeypatch 가 필요 없다는 점이 중요하다: 프로토콜이 좁으면 테스트가 실제 +계약(도구↔스크립트 JSON 규약과 실패 경로)만 검증하게 된다. """ from __future__ import annotations @@ -12,19 +12,32 @@ import pytest +from xgen_agent_runtime.tools._xgeny_sandbox import ExecResult from xgen_agent_runtime.tools.base import ToolContext from xgen_agent_runtime.tools.built_in.sandbox_exec_tool import SandboxExecTool class _FakeSandbox: - container_name = "gapt-ws-tool-test" + """``fn(argv, stdin) -> (rc, out, err)`` 로 결과를 정하는 세션.""" - def __init__(self) -> None: + workdir = "/workspace" + + def __init__(self, fn=None, *, raises: BaseException | None = None) -> None: self.ensured = 0 + self.calls: List[Tuple[Any, ...]] = [] + self._fn = fn or (lambda argv, stdin: (0, b"", b"")) + self._raises = raises async def ensure(self) -> None: self.ensured += 1 + async def exec(self, argv, *, cwd=None, stdin=None, env=None, timeout_s=120.0): + self.calls.append((list(argv), cwd, stdin, timeout_s)) + if self._raises is not None: + raise self._raises + rc, out, err = self._fn(list(argv), stdin) + return ExecResult(rc=rc, stdout=out, stderr=err) + def _spec(**over: Any) -> Dict[str, Any]: base = { @@ -39,30 +52,17 @@ def _spec(**over: Any) -> Dict[str, Any]: return base -def _patch_exec(monkeypatch, fn) -> List[Tuple[Any, ...]]: - """Replace sandbox_exec in the tool module; record (argv, cwd, input).""" - calls: List[Tuple[Any, ...]] = [] - - async def _fake(sandbox, argv, *, cwd, input_bytes=None, env=None, timeout_s=120.0, launcher="docker"): - calls.append((argv, cwd, input_bytes, timeout_s)) - return fn(argv, input_bytes) - - monkeypatch.setattr( - "xgen_agent_runtime.tools.built_in.sandbox_exec_tool.sandbox_exec", _fake - ) - return calls - - @pytest.mark.asyncio -async def test_success_passes_json_stdin_and_returns_stdout(monkeypatch) -> None: +async def test_success_passes_json_stdin_and_returns_stdout() -> None: def run(argv, input_bytes): # argv = [runtime, entrypoint]; input is JSON on stdin. assert argv == ["python3", "tools/echo_upper/main.py"] assert b'"hello"' in input_bytes return (0, b'{"result": "HELLO"}', b"") - calls = _patch_exec(monkeypatch, run) - tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox()) + sb = _FakeSandbox(run) + calls = sb.calls + tool = SandboxExecTool.from_dict(_spec(), sandbox=sb) res = await tool.execute({"text": "hello"}, ToolContext()) assert not res.is_error assert res.content == '{"result": "HELLO"}' @@ -78,17 +78,16 @@ async def test_no_sandbox_is_error() -> None: @pytest.mark.asyncio -async def test_uses_context_sandbox_when_tool_has_none(monkeypatch) -> None: - _patch_exec(monkeypatch, lambda a, i: (0, b'{"ok": true}', b"")) +async def test_uses_context_sandbox_when_tool_has_none() -> None: tool = SandboxExecTool.from_dict(_spec()) # no own sandbox - res = await tool.execute({}, ToolContext(sandbox=_FakeSandbox())) + sb = _FakeSandbox(lambda a, i: (0, b'{"ok": true}', b"")) + res = await tool.execute({}, ToolContext(sandbox=sb)) assert not res.is_error and res.content == '{"ok": true}' @pytest.mark.asyncio -async def test_nonzero_exit_is_error_with_stderr(monkeypatch) -> None: - _patch_exec(monkeypatch, lambda a, i: (2, b"", b"Traceback: boom")) - tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox()) +async def test_nonzero_exit_is_error_with_stderr() -> None: + tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox(lambda a, i: (2, b"", b"Traceback: boom"))) res = await tool.execute({}, ToolContext()) assert res.is_error assert "exit 2" in res.content and "boom" in res.content @@ -96,30 +95,23 @@ async def test_nonzero_exit_is_error_with_stderr(monkeypatch) -> None: @pytest.mark.asyncio -async def test_json_error_payload_is_error(monkeypatch) -> None: - _patch_exec(monkeypatch, lambda a, i: (0, b'{"error": "bad input"}', b"")) - tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox()) +async def test_json_error_payload_is_error() -> None: + tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox(lambda a, i: (0, b'{"error": "bad input"}', b""))) res = await tool.execute({}, ToolContext()) assert res.is_error and res.content == "bad input" @pytest.mark.asyncio -async def test_timeout_is_error(monkeypatch) -> None: - async def _boom(*a, **k): - raise asyncio.TimeoutError() - - monkeypatch.setattr( - "xgen_agent_runtime.tools.built_in.sandbox_exec_tool.sandbox_exec", _boom - ) - tool = SandboxExecTool.from_dict(_spec(timeout_s=3.0), sandbox=_FakeSandbox()) +async def test_timeout_is_error() -> None: + sb = _FakeSandbox(raises=asyncio.TimeoutError()) + tool = SandboxExecTool.from_dict(_spec(timeout_s=3.0), sandbox=sb) res = await tool.execute({}, ToolContext()) assert res.is_error and "timed out" in res.content and res.metadata["timeout"] @pytest.mark.asyncio -async def test_plain_text_stdout_passed_through(monkeypatch) -> None: - _patch_exec(monkeypatch, lambda a, i: (0, b"not json, just text\n", b"")) - tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox()) +async def test_plain_text_stdout_passed_through() -> None: + tool = SandboxExecTool.from_dict(_spec(), sandbox=_FakeSandbox(lambda a, i: (0, b"not json, just text\n", b""))) res = await tool.execute({}, ToolContext()) assert not res.is_error and res.content == "not json, just text" @@ -147,9 +139,9 @@ def test_capabilities_reflect_spec() -> None: @pytest.mark.asyncio async def test_exposed_from_public_packages() -> None: - # built_in package export + container-exec primitives on tools package. + # built_in package export + XGeny sandbox primitives on the tools package. from xgen_agent_runtime.tools.built_in import SandboxExecTool as A - from xgen_agent_runtime.tools import sandbox_exec, SandboxExecError # noqa: F401 + from xgen_agent_runtime.tools import XgenySandbox, SandboxError, sb_run # noqa: F401 from xgen_agent_runtime.tools.built_in import BUILT_IN_TOOL_CLASSES assert A is SandboxExecTool diff --git a/tests/unit/test_sandbox_path_mapping.py b/tests/unit/test_sandbox_path_mapping.py deleted file mode 100644 index 8a59bb2..0000000 --- a/tests/unit/test_sandbox_path_mapping.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Host ↔ container path translation for workspace-unified sandboxes. - -Regression for the split-brain failure (2026-07-15): a HOST-absolute -working_dir passed verbatim to ``docker exec -w`` chdir-killed every -sandboxed Bash/Read call ("Bash has a broken working directory") because -the path did not exist inside the container. -""" - -from __future__ import annotations - -from types import SimpleNamespace - -from xgen_agent_runtime.tools._sandbox import ( - map_into_container, - resolve_container_workdir, -) - -HOST_ROOT = "/data/geny_agent_sessions/abc-123" - - -def _mapped_handle(): - """A handle for a sandbox whose /workspace binds HOST_ROOT/workspace.""" - - def map_path(p: str): - base = HOST_ROOT + "/workspace" - if p == base: - return "/workspace" - if p.startswith(base + "/"): - return "/workspace/" + p[len(base) + 1 :] - return None - - return SimpleNamespace( - container_name="ws", container_workdir="/workspace", map_path=map_path - ) - - -def _legacy_handle(): - return SimpleNamespace(container_name="ws") - - -class TestResolveWorkdir: - def test_mapped_host_workdir(self): - h = _mapped_handle() - assert ( - resolve_container_workdir(h, HOST_ROOT + "/workspace/uploads") - == "/workspace/uploads" - ) - - def test_container_side_workdir_passes_through(self): - h = _mapped_handle() - assert resolve_container_workdir(h, "/workspace/x") == "/workspace/x" - - def test_unmappable_host_workdir_degrades_to_root(self): - """THE regression: host path + no mapping must NOT reach docker -w.""" - h = _legacy_handle() - assert resolve_container_workdir(h, HOST_ROOT) == "/workspace" - - def test_none_workdir_defaults(self): - assert resolve_container_workdir(_legacy_handle(), None) == "/workspace" - - def test_custom_container_workdir(self): - h = SimpleNamespace(container_name="ws", container_workdir="/srv/app") - assert resolve_container_workdir(h, None) == "/srv/app" - assert resolve_container_workdir(h, "/srv/app/sub") == "/srv/app/sub" - - -class TestMapIntoContainer: - def test_host_absolute_file_maps(self): - h = _mapped_handle() - assert ( - map_into_container(h, HOST_ROOT + "/workspace/uploads/a.pptx", HOST_ROOT + "/workspace") - == "/workspace/uploads/a.pptx" - ) - - def test_relative_joins_mapped_workdir(self): - h = _mapped_handle() - assert ( - map_into_container(h, "outputs/deck.pptx", HOST_ROOT + "/workspace") - == "/workspace/outputs/deck.pptx" - ) - - def test_legacy_behaviour_unchanged(self): - h = _legacy_handle() - assert map_into_container(h, "a.txt", "/workspace") == "/workspace/a.txt" - - def test_escape_still_refused(self): - h = _mapped_handle() - import pytest - - with pytest.raises(PermissionError): - map_into_container(h, "../../etc/passwd", "/workspace") - - -class TestExecUser: - def test_exec_user_attribute_recognised(self): - """Handles may pin the exec user (bind workspaces: root aligns with - the host service user that owns the mounted files).""" - h = SimpleNamespace(container_name="ws", exec_user="0:0") - assert getattr(h, "exec_user", None) == "0:0" - legacy = SimpleNamespace(container_name="ws") - assert getattr(legacy, "exec_user", None) is None diff --git a/tests/unit/test_sandbox_tools.py b/tests/unit/test_sandbox_tools.py deleted file mode 100644 index f0a6081..0000000 --- a/tests/unit/test_sandbox_tools.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Built-in fs/shell tools route through the container when ToolContext.sandbox -is set (SDK-path sandboxing). A fake ``docker exec`` simulates a tiny in-container -filesystem so we can assert read/write/edit/bash round-trips — no docker needed. -""" - -from __future__ import annotations - -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - -import pytest - -import xgen_agent_runtime.tools._sandbox as sbmod -from xgen_agent_runtime.tools.base import ToolContext -from xgen_agent_runtime.tools.built_in.bash_tool import BashTool -from xgen_agent_runtime.tools.built_in.edit_tool import EditTool -from xgen_agent_runtime.tools.built_in.read_tool import ReadTool -from xgen_agent_runtime.tools.built_in.write_tool import WriteTool - - -class _FakeSandbox: - container_name = "gapt-ws-t" - - async def ensure(self) -> None: - return None - - -@pytest.fixture -def fake_container_fs(monkeypatch): - """Patch the docker-exec spawn with an in-memory container filesystem.""" - fs: dict[str, bytes] = {} - - def fake_exec(launcher, *argv, **kwargs): - a = list(argv) - ci = a.index("gapt-ws-t") - cmd = a[ci + 1 :] - - class _Proc: - returncode = 0 - - async def communicate(self, input=None): - if cmd[0] == "cat": # cat -- - path = cmd[-1] - if path in fs: - return fs[path], b"" - self.returncode = 1 - return b"", b"cat: no such file or directory" - if cmd[0] == "sh": # sh -c