fix: execute local reins through the sandbox owner
Some checks failed
ci / validate (push) Has been cancelled
Some checks failed
ci / validate (push) Has been cancelled
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
92392f75f1
commit
63a7f9f160
14 changed files with 463 additions and 81 deletions
|
|
@ -11,7 +11,7 @@ import re
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
|
||||
|
||||
CONTRACT_VERSION = "1.0"
|
||||
_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
|
@ -114,6 +114,10 @@ class ReinDescriptor(ContractModel):
|
|||
|
||||
|
||||
class SandboxHandle(ContractModel):
|
||||
# Process-local owner binding; never serialized as reachability or evidence.
|
||||
_owner_execute: Any = PrivateAttr(default=None)
|
||||
_timeout_seconds: int = PrivateAttr(default=600)
|
||||
|
||||
sandbox_id: str
|
||||
host: str
|
||||
reachability: dict[str, Any] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from datetime import UTC, datetime
|
|||
|
||||
from pydantic import ValidationError
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.models import Consumer, SandboxCreateRequest
|
||||
from sandboxer.models import Consumer, SandboxCreateRequest, SandboxExecRequest
|
||||
|
||||
from glas_harness import hub
|
||||
from glas_harness.contract import (
|
||||
|
|
@ -82,7 +82,7 @@ def run_execution(
|
|||
error: str | None = None
|
||||
|
||||
try:
|
||||
consumer = _consumer_from_request(request)
|
||||
consumer = _consumer_from_request(request).model_copy(update={"run_id": request_id})
|
||||
profile, descriptor = catalog.resolve(request.harness_profile_ref)
|
||||
catalog.require_operational(profile)
|
||||
selected_rein = rein or catalog.build_rein(profile, descriptor)
|
||||
|
|
@ -138,6 +138,21 @@ def run_execution(
|
|||
reachability=reachability,
|
||||
)
|
||||
|
||||
def owner_execute(argv, input_text, timeout):
|
||||
return manager.execute(
|
||||
sandbox.sandbox_id,
|
||||
SandboxExecRequest(
|
||||
command=list(argv),
|
||||
consumer=consumer,
|
||||
credential_route_refs=profile.credential_route_refs,
|
||||
stdin_text=input_text,
|
||||
timeout_seconds=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
sandbox._owner_execute = owner_execute
|
||||
sandbox._timeout_seconds = profile.limits.timeout_seconds or 600
|
||||
|
||||
try:
|
||||
session = selected_rein.start_session(
|
||||
profile,
|
||||
|
|
|
|||
|
|
@ -2,21 +2,20 @@
|
|||
|
||||
The source checkout is an input to sandbox provisioning, never an execution
|
||||
workspace. Once sand-boxer returns READY, every rein subprocess crosses exactly
|
||||
one declared boundary: nsenter for a same-host namespace or SSH for a remote
|
||||
workspace.
|
||||
one declared boundary: owner-mediated execution for a same-host namespace or
|
||||
SSH for a remote workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Literal, Sequence
|
||||
from typing import Any, Callable, Literal, Sequence
|
||||
|
||||
from glas_harness.contract import SandboxHandle
|
||||
|
||||
|
|
@ -36,6 +35,8 @@ class ExecutionTransport:
|
|||
workspace: str
|
||||
pid: int | None = None
|
||||
ssh_target: str | None = None
|
||||
owner_execute: Callable | None = None
|
||||
timeout_seconds: int = 600
|
||||
|
||||
def command(self, argv: Sequence[str]) -> list[str]:
|
||||
if not argv:
|
||||
|
|
@ -50,20 +51,7 @@ class ExecutionTransport:
|
|||
*command,
|
||||
]
|
||||
if self.kind == "local_namespace":
|
||||
if self.pid is None:
|
||||
raise TransportError("local namespace transport is missing pid")
|
||||
return [
|
||||
"nsenter",
|
||||
"--target",
|
||||
str(self.pid),
|
||||
"--mount",
|
||||
"--pid",
|
||||
"--net",
|
||||
"--uts",
|
||||
"--ipc",
|
||||
"--",
|
||||
*scoped_command,
|
||||
]
|
||||
raise TransportError("local commands require sandbox owner execution")
|
||||
if self.kind == "ssh":
|
||||
if not self.ssh_target or not _SSH_TARGET.fullmatch(self.ssh_target):
|
||||
raise TransportError(
|
||||
|
|
@ -79,12 +67,31 @@ class ExecutionTransport:
|
|||
input_text: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if not argv:
|
||||
raise TransportError("cannot execute an empty command")
|
||||
bounded_timeout = min(
|
||||
timeout if timeout is not None else self.timeout_seconds,
|
||||
self.timeout_seconds,
|
||||
)
|
||||
if self.kind == "local_namespace":
|
||||
if self.owner_execute is None:
|
||||
raise TransportError("local sandbox requires an owner execution binding")
|
||||
if bounded_timeout < 1:
|
||||
raise TransportError("owner execution timeout must be at least one second")
|
||||
result = self.owner_execute(list(argv), input_text, int(bounded_timeout))
|
||||
if result.timed_out:
|
||||
raise subprocess.TimeoutExpired(list(argv), bounded_timeout)
|
||||
if result.output_truncated:
|
||||
raise TransportError("sandbox owner execution output was truncated")
|
||||
return subprocess.CompletedProcess(
|
||||
list(argv), result.exit_code, result.stdout, result.stderr
|
||||
)
|
||||
return subprocess.run(
|
||||
self.command(argv),
|
||||
input=input_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
timeout=bounded_timeout,
|
||||
)
|
||||
|
||||
def resolve_executable(self, name: str, *, timeout: float = 15.0) -> str:
|
||||
|
|
@ -117,33 +124,20 @@ class ExecutionTransport:
|
|||
# agent's broad `git add -A` cannot accidentally commit its prompt.
|
||||
task_path = str(PurePosixPath(self.workspace) / ".git" / task_name)
|
||||
content = json.dumps(payload)
|
||||
if self.kind == "local_namespace":
|
||||
path = Path(task_path)
|
||||
try:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "w") as task_file:
|
||||
task_file.write(content)
|
||||
except OSError as exc:
|
||||
raise TransportError(f"cannot create sandbox task file: {exc}") from exc
|
||||
return task_path
|
||||
|
||||
script = 'umask 077; cat > "$1"'
|
||||
script = 'umask 077; set -C; cat > "$1"'
|
||||
proc = self.run(["sh", "-c", script, "sh", task_path], input_text=content, timeout=15)
|
||||
if proc.returncode != 0:
|
||||
raise TransportError(
|
||||
"cannot create remote sandbox task file: "
|
||||
"cannot create sandbox task file: "
|
||||
+ (proc.stderr.strip() or f"exit {proc.returncode}")
|
||||
)
|
||||
return task_path
|
||||
|
||||
def remove_file(self, path: str) -> None:
|
||||
if self.kind == "local_namespace":
|
||||
Path(path).unlink(missing_ok=True)
|
||||
return
|
||||
proc = self.run(["rm", "-f", "--", path], timeout=15)
|
||||
if proc.returncode != 0:
|
||||
raise TransportError(
|
||||
"cannot remove remote sandbox task file: "
|
||||
"cannot remove sandbox task file: "
|
||||
+ (proc.stderr.strip() or f"exit {proc.returncode}")
|
||||
)
|
||||
|
||||
|
|
@ -170,8 +164,12 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
|
|||
raise TransportError("local sandbox reachability pid must be an integer") from exc
|
||||
if parsed_pid <= 0 or not Path(str(workspace)).is_absolute():
|
||||
raise TransportError("local sandbox reachability has invalid pid or workspace_dir")
|
||||
if sandbox._owner_execute is None:
|
||||
raise TransportError("local sandbox requires an owner execution binding")
|
||||
return ExecutionTransport(
|
||||
kind="local_namespace", workspace=str(workspace), pid=parsed_pid
|
||||
kind="local_namespace", workspace=str(workspace), pid=parsed_pid,
|
||||
owner_execute=sandbox._owner_execute,
|
||||
timeout_seconds=sandbox._timeout_seconds,
|
||||
)
|
||||
if has_remote:
|
||||
if not ssh_target or not remote_dir:
|
||||
|
|
@ -185,6 +183,7 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
|
|||
if not PurePosixPath(str(remote_dir)).is_absolute():
|
||||
raise TransportError("remote sandbox workspace must be an absolute path")
|
||||
return ExecutionTransport(
|
||||
kind="ssh", workspace=str(remote_dir), ssh_target=str(ssh_target)
|
||||
kind="ssh", workspace=str(remote_dir), ssh_target=str(ssh_target),
|
||||
timeout_seconds=sandbox._timeout_seconds
|
||||
)
|
||||
raise TransportError("sandbox exposes no supported execution reachability")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue