fix: 完善sandbox backend,基于k8s完成所有工具的场景测试;解决创建pod,沙箱接口账号为gem,和容器启动账号root导致权限不一致的问题

This commit is contained in:
supreme0597 2026-03-25 00:00:23 +08:00
parent 7d0a2fb2f1
commit d62b36293d
3 changed files with 218 additions and 421 deletions

View File

@ -62,7 +62,7 @@ class MemoryProvisionerBackend:
return template
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord:
del thread_id
_ = thread_id # unused in memory backend
with self._lock:
existing = self._records.get(sandbox_id)
if existing is not None:
@ -240,31 +240,16 @@ class LocalContainerProvisionerBackend:
raise RuntimeError(f"sandbox {sandbox_id} is not ready at {record.sandbox_url}")
return record
# 检测是否是 Windows 绝对路径 (如 D:/ 或 D:\)
threads_root_str = self._threads_host_path
is_windows_path = len(threads_root_str) >= 2 and threads_root_str[1] == ":"
threads_root = Path(self._threads_host_path).resolve()
thread_user_data = (threads_root / safe_thread_id / "user-data").resolve()
try:
thread_user_data.relative_to(threads_root)
except ValueError as exc:
raise ValueError("thread_id resolved outside threads host root") from exc
thread_user_data.mkdir(parents=True, exist_ok=True)
if is_windows_path:
# Windows 路径,直接使用,不调用 resolve()
threads_root = Path(threads_root_str)
thread_user_data = threads_root / safe_thread_id / "user-data"
# Windows 路径下无法在 Linux 容器内创建目录,跳过 mkdir
else:
threads_root = Path(threads_root_str).resolve()
thread_user_data = (threads_root / safe_thread_id / "user-data").resolve()
try:
thread_user_data.relative_to(threads_root)
except ValueError as exc:
raise ValueError("thread_id resolved outside threads host root") from exc
thread_user_data.mkdir(parents=True, exist_ok=True)
skills_path_str = self._skills_host_path
is_skills_windows = len(skills_path_str) >= 2 and skills_path_str[1] == ":"
if is_skills_windows:
skills_path = Path(skills_path_str)
else:
skills_path = Path(skills_path_str)
skills_path.mkdir(parents=True, exist_ok=True)
skills_path = Path(self._skills_host_path)
skills_path.mkdir(parents=True, exist_ok=True)
container_name = self._container_name(sandbox_id)
run_kwargs = {
@ -280,11 +265,6 @@ class LocalContainerProvisionerBackend:
str(thread_user_data): {"bind": "/home/gem/user-data", "mode": "rw"},
str(skills_path): {"bind": "/skills", "mode": "ro"},
},
"tmpfs": "/tmp:size=256m,mode=1777",
"environment": {
"HOME": "/home/gem/user-data",
"TMPDIR": "/tmp",
},
"ports": {f"{self._container_port}/tcp": None},
"security_opt": ["seccomp=unconfined"],
}
@ -345,12 +325,10 @@ class KubernetesProvisionerBackend:
"SANDBOX_IMAGE",
"enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest",
)
self._skill_pvc = os.getenv("SKILLS_PVC", "yuxi-skills")
self._thread_pvc = os.getenv("THREAD_PVC", "yuxi-thread")
self._node_host = os.getenv("NODE_HOST", "host.docker.internal")
self._container_port = int(os.getenv("SANDBOX_CONTAINER_PORT", "8080"))
self._shared_userdata_pvc = os.getenv("K8S_SHARED_USERDATA_PVC", "shared-userdata-pvc")
self._shared_skills_pvc = os.getenv("K8S_SHARED_SKILLS_PVC", "shared-skills-pvc")
self._allow_shared_pvc_reuse = os.getenv("K8S_ALLOW_SHARED_PVC_REUSE", "false").strip().lower() == "true"
self._ready_timeout_seconds = int(os.getenv("SANDBOX_READY_TIMEOUT_SECONDS", "60"))
self._ready_poll_interval_seconds = max(1, int(os.getenv("SANDBOX_READY_POLL_INTERVAL_SECONDS", "2")))
kubeconfig_path = os.getenv("KUBECONFIG_PATH")
if kubeconfig_path:
@ -364,285 +342,82 @@ class KubernetesProvisionerBackend:
self._core_api = client.CoreV1Api()
self._client = client
@staticmethod
def _validate_thread_id(thread_id: str) -> str:
candidate = str(thread_id or "").strip()
if not candidate:
raise ValueError("thread_id is required")
if any(ch in candidate for ch in ("/", "\\", "\x00")):
raise ValueError("thread_id must be a single safe path segment")
if candidate in {".", ".."} or ".." in candidate:
raise ValueError("thread_id contains invalid path traversal sequence")
return candidate
@staticmethod
def _sanitize_k8s_name(value: str) -> str:
sanitized = "".join(ch if ch.isalnum() else "-" for ch in value.strip().lower())
while "--" in sanitized:
sanitized = sanitized.replace("--", "-")
sanitized = sanitized.strip("-")
return sanitized[:40] or "sandbox"
def _thread_subpath(self, thread_id: str) -> str:
safe_thread_id = self._validate_thread_id(thread_id)
return f"threads/{safe_thread_id}/user-data"
@staticmethod
def _pod_name(sandbox_id: str) -> str:
safe_id = KubernetesProvisionerBackend._sanitize_k8s_name(sandbox_id)
return f"sandbox-{safe_id}"
return f"sandbox-{sandbox_id}"
@staticmethod
def _service_name(sandbox_id: str) -> str:
safe_id = KubernetesProvisionerBackend._sanitize_k8s_name(sandbox_id)
return f"sandbox-{safe_id}"
def _sandbox_url(self, sandbox_id: str) -> str:
service_name = self._service_name(sandbox_id)
return f"http://{service_name}.{self._namespace}.svc.cluster.local:{self._container_port}"
def _read_pvc(self, pvc_name: str):
from kubernetes.client.rest import ApiException
try:
return self._core_api.read_namespaced_persistent_volume_claim(
name=pvc_name,
namespace=self._namespace,
)
except ApiException as exc:
if exc.status == 404:
return None
raise
def _read_pod(self, sandbox_id: str):
from kubernetes.client.rest import ApiException
pod_name = self._pod_name(sandbox_id)
try:
return self._core_api.read_namespaced_pod(name=pod_name, namespace=self._namespace)
except ApiException as exc:
if exc.status == 404:
return None
raise
def _read_service(self, sandbox_id: str):
from kubernetes.client.rest import ApiException
service_name = self._service_name(sandbox_id)
try:
return self._core_api.read_namespaced_service(name=service_name, namespace=self._namespace)
except ApiException as exc:
if exc.status == 404:
return None
raise
def _ensure_shared_pvcs_ready(self) -> None:
for pvc_name in {self._shared_userdata_pvc, self._shared_skills_pvc}:
pvc = self._read_pvc(pvc_name)
if pvc is None:
raise RuntimeError(f"required pvc not found: {pvc_name}")
phase = pvc.status.phase if pvc and pvc.status else None
if phase != "Bound":
raise RuntimeError(f"required pvc is not Bound: {pvc_name} phase={phase}")
def _validate_pvc_configuration(self) -> None:
same_pvc = self._shared_userdata_pvc == self._shared_skills_pvc
if same_pvc and not self._allow_shared_pvc_reuse:
raise RuntimeError(
"K8S_SHARED_USERDATA_PVC and K8S_SHARED_SKILLS_PVC point to the same PVC; "
"set K8S_ALLOW_SHARED_PVC_REUSE=true only if your storage supports this reliably"
)
def _assert_existing_sandbox_matches(self, sandbox_id: str, thread_id: str) -> None:
pod = self._read_pod(sandbox_id)
if pod is None:
return
annotations = (pod.metadata.annotations if pod.metadata else None) or {}
existing_thread_id = annotations.get("thread-id")
if existing_thread_id and existing_thread_id != thread_id:
raise RuntimeError(
f"sandbox {sandbox_id} already exists for thread_id={existing_thread_id}, not {thread_id}"
)
def _pod_ready(self, pod) -> bool:
if not pod or not pod.status or pod.status.phase != "Running":
return False
conditions = pod.status.conditions or []
ready_condition = next((c for c in conditions if c.type == "Ready"), None)
if not ready_condition or ready_condition.status != "True":
return False
container_statuses = pod.status.container_statuses or []
sandbox_container = next((c for c in container_statuses if c.name == "sandbox"), None)
return bool(sandbox_container and sandbox_container.ready is True)
def _extract_pod_failure(self, pod) -> str | None:
if not pod or not pod.status:
return None
if pod.status.phase == "Failed":
reason = pod.status.reason or "Unknown"
message = pod.status.message or ""
return f"pod failed: {reason} {message}".strip()
for cond in pod.status.conditions or []:
if cond.type == "PodScheduled" and cond.status == "False":
return f"pod scheduling failed: {(cond.reason or '').strip()} {(cond.message or '').strip()}".strip()
for container_status in pod.status.container_statuses or []:
state = container_status.state
if state and state.waiting:
reason = state.waiting.reason or "Waiting"
message = state.waiting.message or ""
if reason in {
"ImagePullBackOff",
"ErrImagePull",
"CrashLoopBackOff",
"CreateContainerConfigError",
"CreateContainerError",
}:
return f"container {container_status.name} waiting: {reason} {message}".strip()
if state and state.terminated:
reason = state.terminated.reason or "Terminated"
exit_code = state.terminated.exit_code
message = state.terminated.message or ""
return f"container {container_status.name} terminated: {reason} exit={exit_code} {message}".strip()
return None
return f"sandbox-{sandbox_id}"
def _build_pod_spec(self, sandbox_id: str, thread_id: str):
pod_name = self._pod_name(sandbox_id)
safe_thread_id = self._validate_thread_id(thread_id)
user_data_subpath = self._thread_subpath(safe_thread_id)
same_pvc = self._shared_userdata_pvc == self._shared_skills_pvc
if same_pvc:
volumes = [
self._client.V1Volume(
name="shared-storage",
persistent_volume_claim=self._client.V1PersistentVolumeClaimVolumeSource(
claim_name=self._shared_userdata_pvc,
),
)
]
volume_mounts = [
self._client.V1VolumeMount(
name="shared-storage",
mount_path="/home/gem/user-data",
sub_path=user_data_subpath,
),
self._client.V1VolumeMount(
name="shared-storage",
mount_path="/skills",
sub_path="skills",
read_only=True,
),
]
else:
volumes = [
self._client.V1Volume(
name="user-data",
persistent_volume_claim=self._client.V1PersistentVolumeClaimVolumeSource(
claim_name=self._shared_userdata_pvc,
),
),
self._client.V1Volume(
name="skills",
persistent_volume_claim=self._client.V1PersistentVolumeClaimVolumeSource(
claim_name=self._shared_skills_pvc,
read_only=True,
),
),
]
volume_mounts = [
self._client.V1VolumeMount(
name="user-data",
mount_path="/home/gem/user-data",
sub_path=user_data_subpath,
),
self._client.V1VolumeMount(
name="skills",
mount_path="/skills",
sub_path="skills",
read_only=True,
),
]
volume_mounts.append(self._client.V1VolumeMount(name="tmp", mount_path="/tmp"))
volumes.append(
self._client.V1Volume(
name="tmp",
empty_dir=self._client.V1EmptyDirVolumeSource(medium="Memory"),
)
)
init_script = f'set -eu; mkdir -p "/home/gem/user-data/threads/{safe_thread_id}/user-data"; test -d "/skills"'
init_volume_mounts = [
self._client.V1VolumeMount(name="tmp", mount_path="/tmp"),
]
if same_pvc:
init_volume_mounts.extend(
[
self._client.V1VolumeMount(name="shared-storage", mount_path="/home/gem/user-data"),
self._client.V1VolumeMount(name="shared-storage", mount_path="/skills", sub_path="skills"),
]
)
else:
init_volume_mounts.extend(
[
self._client.V1VolumeMount(name="user-data", mount_path="/home/gem/user-data"),
self._client.V1VolumeMount(name="skills", mount_path="/skills", sub_path="skills", read_only=True),
]
)
return self._client.V1Pod(
metadata=self._client.V1ObjectMeta(
name=pod_name,
labels={
"app": "yuxi-sandbox",
"sandbox-id": sandbox_id,
"managed-by": "yuxi-sandbox-provisioner",
},
annotations={"thread-id": safe_thread_id},
labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id},
annotations={"thread-id": thread_id},
),
spec=self._client.V1PodSpec(
restart_policy="Never",
security_context=self._client.V1PodSecurityContext(fs_group=1000),
security_context=self._client.V1PodSecurityContext(
fs_group=0,
run_as_user=0,
),
init_containers=[
self._client.V1Container(
name="prepare-user-data",
name="init-user-data",
image=self._sandbox_image,
command=["sh", "-lc", init_script],
volume_mounts=init_volume_mounts,
)
command=["sh", "-c"],
args=[
"chmod 777 /home/gem "
"&& mkdir -p /home/gem/user-data/workspace /home/gem/user-data/uploads /home/gem/user-data/outputs "
"&& chmod -R 777 /home/gem/user-data ",
],
volume_mounts=[
self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/gem/user-data",
sub_path=f"threads/{thread_id}/user-data",
),
],
),
],
containers=[
self._client.V1Container(
name="sandbox",
image=self._sandbox_image,
ports=[self._client.V1ContainerPort(container_port=self._container_port)],
env=[
self._client.V1EnvVar(name="HOME", value="/home/gem/user-data"),
self._client.V1EnvVar(name="TMPDIR", value="/tmp"),
volume_mounts=[
self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/gem/user-data",
sub_path=f"threads/{thread_id}/user-data",
),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/skills",
sub_path="skills",
read_only=False,
),
],
volume_mounts=volume_mounts,
readiness_probe=self._client.V1Probe(
http_get=self._client.V1HTTPGetAction(
path="/v1/sandbox",
port=self._container_port,
),
period_seconds=2,
timeout_seconds=2,
failure_threshold=15,
),
startup_probe=self._client.V1Probe(
http_get=self._client.V1HTTPGetAction(
path="/v1/sandbox",
port=self._container_port,
),
period_seconds=2,
timeout_seconds=2,
failure_threshold=30,
),
)
],
volumes=volumes,
volumes=[
self._client.V1Volume(
name="shared-data",
persistent_volume_claim=self._client.V1PersistentVolumeClaimVolumeSource(
claim_name=self._thread_pvc,
read_only=False,
),
),
self._client.V1Volume(
name="home-dir",
empty_dir=self._client.V1EmptyDirVolumeSource(),
),
],
),
)
@ -651,14 +426,10 @@ class KubernetesProvisionerBackend:
return self._client.V1Service(
metadata=self._client.V1ObjectMeta(
name=service_name,
labels={
"app": "yuxi-sandbox",
"sandbox-id": sandbox_id,
"managed-by": "yuxi-sandbox-provisioner",
},
labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id},
),
spec=self._client.V1ServiceSpec(
type="ClusterIP",
type="NodePort",
selector={"sandbox-id": sandbox_id},
ports=[
self._client.V1ServicePort(
@ -671,55 +442,21 @@ class KubernetesProvisionerBackend:
),
)
def wait_ready(self, sandbox_id: str) -> SandboxRecord:
deadline = time.monotonic() + self._ready_timeout_seconds
sandbox_url = self._sandbox_url(sandbox_id)
last_failure = None
while time.monotonic() < deadline:
pod = self._read_pod(sandbox_id)
service = self._read_service(sandbox_id)
if pod is None or service is None:
time.sleep(self._ready_poll_interval_seconds)
continue
failure = self._extract_pod_failure(pod)
if failure:
raise RuntimeError(f"sandbox {sandbox_id} failed before ready: {failure}")
if self._pod_ready(pod):
if wait_for_sandbox_ready(sandbox_url, timeout_seconds=3):
return SandboxRecord(
sandbox_id=sandbox_id,
sandbox_url=sandbox_url,
status="Running",
)
last_failure = f"http health check not ready at {sandbox_url}"
time.sleep(self._ready_poll_interval_seconds)
raise RuntimeError(f"sandbox {sandbox_id} timed out waiting ready: {last_failure or 'unknown'}")
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord:
from kubernetes.client.rest import ApiException
with self._lock:
safe_thread_id = self._validate_thread_id(thread_id)
self._validate_pvc_configuration()
self._ensure_shared_pvcs_ready()
discovered = self.discover(sandbox_id)
if discovered is not None:
return discovered
existing_pod = self._read_pod(sandbox_id)
existing_service = self._read_service(sandbox_id)
if existing_pod is not None:
self._assert_existing_sandbox_matches(sandbox_id, safe_thread_id)
if existing_pod is not None and existing_service is not None:
return self.wait_ready(sandbox_id)
self._pod_name(sandbox_id)
self._service_name(sandbox_id)
try:
self._core_api.create_namespaced_pod(
namespace=self._namespace,
body=self._build_pod_spec(sandbox_id, safe_thread_id),
body=self._build_pod_spec(sandbox_id, thread_id),
)
except ApiException as exc:
if exc.status != 409:
@ -734,27 +471,43 @@ class KubernetesProvisionerBackend:
if exc.status != 409:
raise
return self.wait_ready(sandbox_id)
health_timeout = int(os.getenv("SANDBOX_HEALTH_TIMEOUT_SECONDS", "60"))
record = self.discover(sandbox_id)
if record is None:
raise RuntimeError(f"failed to discover sandbox after create: {sandbox_id}")
if not wait_for_sandbox_ready(record.sandbox_url, timeout_seconds=health_timeout):
try:
self.delete(sandbox_id)
except Exception:
pass
raise RuntimeError(f"sandbox {sandbox_id} is not ready at {record.sandbox_url}")
return record
def discover(self, sandbox_id: str) -> SandboxRecord | None:
pod = self._read_pod(sandbox_id)
service = self._read_service(sandbox_id)
if pod is None or service is None:
return None
from kubernetes.client.rest import ApiException
failure = self._extract_pod_failure(pod)
if failure:
status = f"Failed:{failure}"
elif self._pod_ready(pod):
status = "Running"
pod_name = self._pod_name(sandbox_id)
service_name = self._service_name(sandbox_id)
try:
pod = self._core_api.read_namespaced_pod(name=pod_name, namespace=self._namespace)
service = self._core_api.read_namespaced_service(name=service_name, namespace=self._namespace)
except ApiException as exc:
if exc.status == 404:
return None
raise
node_port = None
if service.spec and service.spec.ports:
node_port = service.spec.ports[0].node_port
if not node_port:
sandbox_url = ""
else:
phase = pod.status.phase if pod and pod.status else "Unknown"
status = f"NotReady:{phase}"
sandbox_url = f"http://{self._node_host}:{node_port}"
return SandboxRecord(
sandbox_id=sandbox_id,
sandbox_url=self._sandbox_url(sandbox_id),
status=status,
sandbox_url=sandbox_url,
status=(pod.status.phase if pod and pod.status else "Unknown"),
)
def list(self) -> list[SandboxRecord]:
@ -803,7 +556,7 @@ class SandboxIdleReaper:
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._exec_timeout_seconds = int(os.getenv("SANDBOX_EXEC_TIMEOUT_SECONDS", "180"))
configured_idle_timeout = int(os.getenv("SANDBOX_IDLE_TIMEOUT_SECONDS", "120"))
configured_idle_timeout = int(os.getenv("SANDBOX_IDLE_TIMEOUT_SECONDS", "600"))
if 0 < configured_idle_timeout <= self._exec_timeout_seconds:
logger.warning(
"SANDBOX_IDLE_TIMEOUT_SECONDS=%s is <= SANDBOX_EXEC_TIMEOUT_SECONDS=%s; "
@ -913,6 +666,7 @@ def health():
@app.post("/api/sandboxes", response_model=SandboxResponse)
def create_sandbox(payload: CreateSandboxRequest):
try:
# Backend.create() already handles container reuse (discovers existing container first)
record = backend_impl.create(payload.sandbox_id, payload.thread_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -1,13 +1,71 @@
from __future__ import annotations
from deepagents.backends import CompositeBackend
from deepagents.backends.composite import (
CompositeBackend,
_route_for_path,
_remap_file_info_path,
_strip_route_from_pattern,
)
from deepagents.backends.protocol import FileInfo
from src.agents.common.middlewares.skills_middleware import normalize_selected_skills
from src.sandbox import ProvisionerSandboxBackend
from .skills_backend import SelectedSkillsReadonlyBackend
class CustomCompositeBackend(CompositeBackend):
"""修复 glob_info 路由逻辑的 CompositeBackend。
修复内容 path 不匹配任何路由时应该只搜索 default 后端
而不是错误地遍历所有路由后端搜索
"""
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
backend, backend_path, route_prefix = _route_for_path(
default=self.default,
sorted_routes=self.sorted_routes,
path=path,
)
if route_prefix is not None:
infos = backend.glob_info(pattern, backend_path)
return [_remap_file_info_path(fi, route_prefix) for fi in infos]
# 只在 path 为 None 或 "/" 时搜索所有后端,其他只搜索 default
if path is None or path == "/":
results: list[FileInfo] = []
results.extend(self.default.glob_info(pattern, path))
for route_prefix, backend in self.routes.items():
route_pattern = _strip_route_from_pattern(pattern, route_prefix)
infos = backend.glob_info(route_pattern, "/")
results.extend(_remap_file_info_path(fi, route_prefix) for fi in infos)
results.sort(key=lambda x: x.get("path", ""))
return results
return self.default.glob_info(pattern, path)
async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
backend, backend_path, route_prefix = _route_for_path(
default=self.default,
sorted_routes=self.sorted_routes,
path=path,
)
if route_prefix is not None:
infos = await backend.aglob_info(pattern, backend_path)
return [_remap_file_info_path(fi, route_prefix) for fi in infos]
if path is None or path == "/":
results: list[FileInfo] = []
results.extend(await self.default.aglob_info(pattern, path))
for route_prefix, backend in self.routes.items():
route_pattern = _strip_route_from_pattern(pattern, route_prefix)
infos = await backend.aglob_info(route_pattern, "/")
results.extend(_remap_file_info_path(fi, route_prefix) for fi in infos)
results.sort(key=lambda x: x.get("path", ""))
return results
return await self.default.aglob_info(pattern, path)
def _get_visible_skills_from_runtime(runtime) -> list[str]:
"""获取运行时可见的 skills 列表"""
context = getattr(runtime, "context", None)
@ -35,7 +93,7 @@ def _extract_thread_id(runtime) -> str:
def create_agent_composite_backend(runtime) -> CompositeBackend:
visible_skills = _get_visible_skills_from_runtime(runtime)
thread_id = _extract_thread_id(runtime)
return CompositeBackend(
return CustomCompositeBackend(
default=ProvisionerSandboxBackend(thread_id=thread_id),
routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),

View File

@ -71,16 +71,23 @@ class ProvisionerSandboxBackend(BaseSandbox):
return self._client
def _read_binary(self, path: str) -> bytes:
def _read_binary(self, path: str, offset: int = 0, limit: int | None = None) -> bytes:
"""Read file content from the sandbox file API and normalize it to bytes.
The underlying API may return base64 text, raw bytes, or plain strings.
This helper is the single normalization point used by read(), edit(), and
download_files() so all read paths share the same transport semantics.
"""
result = self._get_client().file.read_file(file=path, encoding="base64")
start_line = max(0, int(offset)) if offset else None
end_line = (start_line + int(limit)) if limit and start_line is not None else None
content = result.content
result = self._get_client().file.read_file(
file=path,
start_line=start_line,
end_line=end_line,
)
content = result.data.content
if content is None:
return b""
if isinstance(content, bytes):
@ -106,10 +113,9 @@ class ProvisionerSandboxBackend(BaseSandbox):
"""
normalized_path = _normalize_path(file_path)
start = max(0, int(offset))
size = max(0, int(limit))
try:
content = self._read_binary(normalized_path)
content = self._read_binary(normalized_path, offset=offset, limit=limit)
except Exception: # noqa: BLE001
return f"Error: File '{file_path}' not found"
@ -117,13 +123,11 @@ class ProvisionerSandboxBackend(BaseSandbox):
return "System reminder: File exists but has empty contents"
text = content.decode("utf-8", errors="replace")
lines = text.splitlines()
selected_lines = lines[start : start + size]
if not selected_lines:
if not text:
return ""
return "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected_lines))
lines = text.splitlines()
return "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(lines))
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
"""Execute a shell command in the sandbox.
@ -137,19 +141,8 @@ class ProvisionerSandboxBackend(BaseSandbox):
kwargs["timeout"] = timeout
result = self._get_client().shell.exec_command(**kwargs)
output = getattr(result, "output", None)
if output is None and isinstance(result, dict):
output = result.get("output")
if output is None:
output = str(result) if result is not None else ""
if not isinstance(output, str):
output = str(output)
exit_code = getattr(result, "exit_code", None)
if exit_code is None and isinstance(result, dict):
exit_code = result.get("exit_code")
if isinstance(exit_code, str) and exit_code.isdigit():
exit_code = int(exit_code)
output = result.data.output or ""
exit_code = result.data.exit_code
truncated = False
encoded = output.encode("utf-8", errors="ignore")
@ -174,7 +167,7 @@ class ProvisionerSandboxBackend(BaseSandbox):
except Exception: # noqa: BLE001
return []
entries = result.files or []
entries = result.data.files or []
infos: list[FileInfo] = []
for entry in entries:
info: FileInfo = {"path": entry.path, "is_dir": entry.is_directory}
@ -182,11 +175,16 @@ class ProvisionerSandboxBackend(BaseSandbox):
if isinstance(size, int):
info["size"] = size
modified_time = entry.modified_time
if isinstance(modified_time, str) and modified_time:
try:
info["modified_at"] = datetime.fromisoformat(modified_time).isoformat()
except ValueError:
info["modified_at"] = modified_time
if modified_time:
if isinstance(modified_time, str) and modified_time.isdigit():
info["modified_at"] = datetime.fromtimestamp(int(modified_time)).isoformat()
elif isinstance(modified_time, str):
try:
info["modified_at"] = datetime.fromisoformat(modified_time).isoformat()
except ValueError:
info["modified_at"] = modified_time
elif isinstance(modified_time, (int, float)):
info["modified_at"] = datetime.fromtimestamp(modified_time).isoformat()
infos.append(info)
return infos
@ -207,7 +205,9 @@ class ProvisionerSandboxBackend(BaseSandbox):
return WriteResult(error=f"Error: File '{file_path}' already exists")
try:
self._get_client().file.write_file(file=normalized_path, content=content)
result = self._get_client().file.write_file(file=normalized_path, content=content)
if not result.success:
return WriteResult(error=result.message or f"Failed to write file '{file_path}'")
except Exception as exc: # noqa: BLE001
return WriteResult(error=str(exc) or f"Failed to write file '{file_path}'")
@ -226,6 +226,8 @@ class ProvisionerSandboxBackend(BaseSandbox):
are not supported here and should be handled via download/upload flows.
"""
normalized_path = _normalize_path(file_path)
# Check if old_string exists
try:
text = self._read_binary(normalized_path).decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
@ -242,12 +244,20 @@ class ProvisionerSandboxBackend(BaseSandbox):
)
)
updated = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
# Use str_replace_editor API
replace_mode = "ALL" if replace_all else "FIRST"
try:
self._get_client().file.write_file(file=normalized_path, content=updated)
result = self._get_client().file.str_replace_editor(
command="str_replace",
path=normalized_path,
old_str=old_string,
new_str=new_string,
replace_mode=replace_mode,
)
if not result.data.success:
return EditResult(error=result.data.message or f"Error editing file '{file_path}'")
except Exception as exc: # noqa: BLE001
return EditResult(error=f"Error editing file (exit code 1): {exc or 'Unknown error'}")
return EditResult(error=f"Error editing file: {exc}")
return EditResult(path=normalized_path, files_update=None, occurrences=count if replace_all else 1)
@ -263,56 +273,29 @@ class ProvisionerSandboxBackend(BaseSandbox):
optional include glob.
"""
search_path = _normalize_path(path or "/")
include = [glob] if glob else None
try:
result = self._get_client().file.grep_files(
path=search_path,
pattern=pattern,
include=include,
fixed_strings=True,
recursive=True,
)
return super().grep_raw(pattern=pattern, path=search_path, glob=glob)
except Exception as exc: # noqa: BLE001
return str(exc)
matches_out: list[GrepMatch] = []
for match in result.matches or []:
matches_out.append(
{
"path": match.file,
"line": match.line_number,
"text": match.line_content,
}
)
return matches_out
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
"""Return files matching a glob pattern with optional metadata."""
normalized_path = _normalize_path(path)
try:
result = self._get_client().file.glob_files(
# return super().glob_info(pattern=pattern, path=path)
result = self._get_client().file.find_files(
path=normalized_path,
pattern=pattern,
include_metadata=True,
glob=pattern,
)
except Exception: # noqa: BLE001
return []
infos: list[FileInfo] = []
for entry in result.files or []:
info: FileInfo = {"path": entry.path}
if isinstance(entry.is_directory, bool):
info["is_dir"] = entry.is_directory
if isinstance(entry.size, int):
info["size"] = entry.size
if isinstance(entry.modified_time, str) and entry.modified_time:
try:
info["modified_at"] = datetime.fromisoformat(entry.modified_time).isoformat()
except ValueError:
info["modified_at"] = entry.modified_time
infos.append(info)
for file_path in result.data.files or []:
infos.append({"path": file_path})
return infos
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
@ -325,11 +308,13 @@ class ProvisionerSandboxBackend(BaseSandbox):
for path, content in files:
try:
normalized_path = _normalize_path(path)
self._get_client().file.write_file(
result = self._get_client().file.write_file(
file=normalized_path,
content=base64.b64encode(content).decode("ascii"),
encoding="base64",
)
if not result.success:
raise Exception(result.message or "Upload failed")
responses.append(FileUploadResponse(path=normalized_path, error=None))
except PermissionError:
normalized_path = str(path)