Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 0 additions & 6 deletions src/xgen_agent_runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,9 @@
ClientCapabilities,
ClientRegistry,
ConfigError,
ContainerCLIRunner,
ContentBlock,
CredentialBundle,
ProviderCredentials,
SandboxHandle,
build_container_cli_client,
)
from xgen_agent_runtime.memory import (
GenyPresets,
Expand Down Expand Up @@ -206,12 +203,9 @@
"ClientCapabilities",
"ClientRegistry",
"ConfigError",
"ContainerCLIRunner",
"ContentBlock",
"CredentialBundle",
"ProviderCredentials",
"SandboxHandle",
"build_container_cli_client",
# Errors
"GenyExecutorError",
"PipelineError",
Expand Down
62 changes: 11 additions & 51 deletions src/xgen_agent_runtime/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 0 additions & 6 deletions src/xgen_agent_runtime/llm_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
104 changes: 0 additions & 104 deletions src/xgen_agent_runtime/llm_client/_cli_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@
Mapping,
NamedTuple,
Optional,
Protocol,
Sequence,
runtime_checkable,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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

<launcher> exec -i -w <workdir> --env K=V ... <container> <bin> <argv>

so the agent only ever sees the container's ``<workdir>`` (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
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading