This commit is contained in:
parent
1cd890d871
commit
f773b5c101
19 changed files with 865 additions and 181 deletions
|
|
@ -1,27 +1,53 @@
|
|||
name: CI Smoke
|
||||
# Forgejo Actions CI for glas-harness.
|
||||
# ubuntu-latest maps to a minimal node:20-bookworm runner, so use archive
|
||||
# checkout and install Python explicitly. sand-boxer is a sibling source
|
||||
# dependency and is fetched separately for contract tests.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
host-smoke:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Routing probe (host runner)
|
||||
run: |
|
||||
set -eu
|
||||
echo "repository=${GITHUB_REPOSITORY:-unknown}"
|
||||
echo "sha=${GITHUB_SHA:-unknown}"
|
||||
echo "runner=${RUNNER_NAME:-unknown}"
|
||||
uname -a
|
||||
|
||||
container-smoke:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Routing probe (container label)
|
||||
- name: Fetch glas-harness source archive
|
||||
env:
|
||||
REF: ${{ github.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "glas-harness container-smoke ok"
|
||||
SHORT="${REF:0:7}"
|
||||
mkdir -p /tmp/glas-harness
|
||||
wget -qO /tmp/glas-harness.tar.gz \
|
||||
"https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
|
||||
tar xzf /tmp/glas-harness.tar.gz -C /tmp/glas-harness --strip-components=1
|
||||
|
||||
- name: Fetch sand-boxer contract dependency
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p /tmp/sand-boxer
|
||||
wget -qO /tmp/sand-boxer.tar.gz \
|
||||
"https://forgejo.coulomb.social/coulomb/sand-boxer/archive/main.tar.gz"
|
||||
tar xzf /tmp/sand-boxer.tar.gz -C /tmp/sand-boxer --strip-components=1
|
||||
|
||||
- name: Install Python and packages
|
||||
working-directory: /tmp/glas-harness
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-pip python3-venv >/dev/null
|
||||
python3 -m pip install --break-system-packages --upgrade pip
|
||||
python3 -m pip install --break-system-packages /tmp/sand-boxer
|
||||
python3 -m pip install --break-system-packages ".[dev]"
|
||||
|
||||
- name: Run unit and boundary tests
|
||||
working-directory: /tmp/glas-harness
|
||||
run: python3 -m pytest tests/ -q
|
||||
|
||||
- name: Validate packaged execution catalog
|
||||
working-directory: /tmp/glas-harness
|
||||
run: glas-harness profiles
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ There is no governed default rein, model, sandbox, or tool profile. Unknown,
|
|||
disabled, incompatible, and ambiguous selections are refused before sandbox
|
||||
creation.
|
||||
|
||||
After creation, execution uses only the reachability-derived sandbox workspace:
|
||||
same-host namespaces are entered with `nsenter`, while remote workspaces are
|
||||
reached over SSH. The source checkout is provisioning input, not a rein workdir.
|
||||
|
||||
See [SCOPE.md](SCOPE.md), [INTENT.md](INTENT.md),
|
||||
[docs/execution-profiles.md](docs/execution-profiles.md), and
|
||||
[docs/harness-contract.md](docs/harness-contract.md).
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
| workplan | GLAS-WP-0002 | finished | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||
| workplan | GLAS-WP-0003 | finished | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||
| workplan | GLAS-WP-0004 | finished | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md |
|
||||
| workplan | GLAS-WP-0005 | active | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||
| task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||
| task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||
|
|
@ -38,4 +39,10 @@
|
|||
| task | GLAS-WP-0004-T06 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md |
|
||||
| task | GLAS-WP-0004-T07 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md |
|
||||
| task | GLAS-WP-0004-T08 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md |
|
||||
| task | GLAS-WP-0005-T01 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| task | GLAS-WP-0005-T02 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| task | GLAS-WP-0005-T03 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| task | GLAS-WP-0005-T04 | progress | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| task | GLAS-WP-0005-T05 | wait | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
|
||||
| intake | GLAS-IN-0001 | done | blue | docs/intakes/residuals.md |
|
||||
| intake | GLAS-IN-0002 | todo | red | docs/intakes/residuals.md |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ Acceptance task for both profiles: change one `README.md` status value from
|
|||
used a separate temporary initialized repository and `profile.bwrap-local`.
|
||||
No credential value, prompt transcript, or raw model output is recorded here.
|
||||
|
||||
> **Correction recorded 2026-08-21 (GLAS-WP-0005):** these runs proved profile
|
||||
> resolution, rein dispatch, normalized evidence, commit detection, and
|
||||
> sand-boxer lifecycle teardown. They did **not** prove that the rein process ran
|
||||
> inside bwrap. The gateway passed the source checkout to the adapters and the
|
||||
> adapters launched ordinary host subprocesses instead of entering the reported
|
||||
> namespace. The commit and model results below remain valid execution facts;
|
||||
> the earlier isolation implication is withdrawn. GLAS-WP-0005 changes the
|
||||
> runtime to use only reachability-derived workspaces and transports and requires
|
||||
> a new boundary proof.
|
||||
|
||||
## `harness.agent-dev-local@1.0.0`
|
||||
|
||||
- Result: succeeded.
|
||||
|
|
@ -54,7 +64,8 @@ rotation.
|
|||
|
||||
Both explicit profiles resolved and dispatched distinct rein/model
|
||||
constellations, completed the same semantic task with a real commit, returned
|
||||
the common Glas evidence envelope, and destroyed their local sandboxes. No
|
||||
the common Glas evidence envelope, and destroyed their local sandbox lifecycle
|
||||
records. Per the correction above, this is not isolation evidence. No
|
||||
alternative workload credential was borrowed.
|
||||
|
||||
Rollback for consumers is to repin to the last approved Glas profile. The old
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@ enabled revision exists. Inline secrets and token-looking values are refused.
|
|||
|
||||
Use `glas-harness profiles` as the catalog/packaging validation command.
|
||||
|
||||
Catalog validation proves schema/compatibility, not host installation. An
|
||||
execution host must install `glas-harness` with sand-boxer support and each rein
|
||||
used by its enabled profiles. The Claude-backed rein also needs its documented
|
||||
`rein-aharness[llm]`/sibling `llm-connect` adapter dependency. Missing runtime
|
||||
dependencies surface as startup/execution failure evidence and still trigger
|
||||
sandbox teardown.
|
||||
Catalog validation proves schema/compatibility, not transport installation. An
|
||||
execution host must install `glas-harness` with sand-boxer support and make each
|
||||
selected rein plus its dependencies available *inside* the sandbox transport,
|
||||
not merely on the host. Same-host bwrap execution enters the descriptor's
|
||||
namespace; remote execution crosses its SSH endpoint. The Claude-backed rein
|
||||
also needs its documented `rein-aharness[llm]`/sibling `llm-connect` adapter
|
||||
dependency in that environment. Missing runtime dependencies fail closed at
|
||||
session start and still trigger sandbox teardown.
|
||||
|
||||
## Consumer request
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,15 @@ from the State Hub detail.
|
|||
class Rein(ABC):
|
||||
def start_session(
|
||||
self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle
|
||||
) -> dict[str, str]: ...
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def dispatch_tool(
|
||||
self, session: dict[str, str], tool_call: ToolCall
|
||||
self, session: dict[str, Any], tool_call: ToolCall
|
||||
) -> ToolResult: ...
|
||||
|
||||
def end_session(self, session: dict[str, str]) -> ExecutionSummary: ...
|
||||
def end_session(self, session: dict[str, Any]) -> ExecutionSummary: ...
|
||||
|
||||
def cleanup_session(self, session: dict[str, Any]) -> None: ...
|
||||
```
|
||||
|
||||
```text
|
||||
|
|
@ -44,13 +46,24 @@ Glas rein sand-boxer
|
|||
---- ---- ----------
|
||||
resolve profile + rein descriptor
|
||||
create sandbox ------------------------------------------> create
|
||||
start_session(profile, inputs, handle) -> rein setup
|
||||
dispatch_tool(session, call) -> inner loop
|
||||
derive workspace + transport from handle
|
||||
start_session(profile, inputs, handle) -> sandbox-local setup
|
||||
dispatch_tool(session, call) -> inner loop across transport
|
||||
end_session(session) -> normalized facts
|
||||
cleanup_session(session) -> remove ephemeral task material
|
||||
destroy sandbox -----------------------------------------> destroy
|
||||
publish compact ExecutionEvidence
|
||||
```
|
||||
|
||||
After sandbox creation, the caller's source checkout is no longer an execution
|
||||
path. A same-host descriptor must contain `pid` plus `workspace_dir` and every
|
||||
rein command is wrapped with `nsenter`; a remote descriptor must contain `ssh`
|
||||
plus `remote_dir` and every command crosses SSH. Incomplete, mixed, or unknown
|
||||
reachability refuses at session start. The transport also bounds the outer rein
|
||||
subprocess with the profile timeout. A host must make the selected rein command
|
||||
and its dependencies available inside that transport; host-only installation is
|
||||
not treated as sandbox availability.
|
||||
|
||||
There is no production default rein. Direct `Rein` injection remains a narrow
|
||||
library/test seam but still requires a valid profile so profile, sandbox,
|
||||
model, tool policy, and evidence are explicit.
|
||||
|
|
@ -86,8 +99,9 @@ The gateway returns evidence for every normal refusal/failure path:
|
|||
- `teardown`.
|
||||
|
||||
Profile resolution happens before sandbox creation. Once a sandbox exists,
|
||||
teardown is attempted on every path. `refused` means governed execution did not
|
||||
proceed; `failed` means an attempted lifecycle did not complete successfully.
|
||||
rein cleanup and sandbox teardown are attempted on every path. `refused` means
|
||||
governed execution did not proceed; `failed` means an attempted lifecycle did
|
||||
not complete successfully.
|
||||
|
||||
## Deliberate non-goals
|
||||
|
||||
|
|
|
|||
|
|
@ -36,3 +36,38 @@ description: |
|
|||
commit b0600b25066731c6e1fc458409429f76a844f959, and sandbox 220482bc was
|
||||
verified destroyed. The AppRole directory is now the code default.
|
||||
```
|
||||
|
||||
## GLAS-IN-0002 — Provide executable sandbox runtime reachability
|
||||
|
||||
```yaml
|
||||
id: GLAS-IN-0002
|
||||
kind: intake
|
||||
title: "Make bwrap reachability executable for governed Glas reins"
|
||||
lane: red
|
||||
status: todo
|
||||
priority: high
|
||||
owner: sand-boxer
|
||||
repo: glas-harness
|
||||
origin: residual
|
||||
origin_ref: GLAS-WP-0005
|
||||
description: |
|
||||
GLAS-WP-0005 corrected the gateway so a rein command must execute through
|
||||
the sand-boxer reachability descriptor and can no longer operate on the
|
||||
caller's source checkout. The existing profile.bwrap-local environment does
|
||||
not yet carry an executable rein runtime: ext.bwrap mounts /usr, /bin, /lib,
|
||||
/lib64, and resolv.conf, while rein-aharness/rein-openweights and their
|
||||
dependencies live outside those mounts. The open-weight profile also needs a
|
||||
governed egress/credential-delivery answer rather than the current empty
|
||||
network allow-list.
|
||||
|
||||
A 2026-08-21 managed-agent probe also found direct consumer nsenter denied on
|
||||
the reported pid (IPC/UTS setns operation not permitted). Determine whether
|
||||
sand-boxer should expose an owner-implemented exec operation instead of
|
||||
requiring each consumer to call nsenter, and provide the runtime mount/image,
|
||||
identity, and network contract needed by both local profiles. Do not restore
|
||||
host-side execution as a workaround.
|
||||
|
||||
Done when a non-secret probe and one real rein command execute inside the
|
||||
namespace, the source checkout is not visible/mutable, required egress is
|
||||
explicit, and teardown removes the sandbox workspace.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ version = "0.1.0"
|
|||
description = "Meta-framework routing between concrete agent-harness backends (reins)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -205,13 +205,16 @@ class Rein(ABC):
|
|||
@abstractmethod
|
||||
def start_session(
|
||||
self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, Any]:
|
||||
"""Begin an agent session bound to a sandbox."""
|
||||
|
||||
@abstractmethod
|
||||
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
||||
def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult:
|
||||
"""Run one tool call under the session policy."""
|
||||
|
||||
@abstractmethod
|
||||
def end_session(self, session: dict[str, str]) -> ExecutionSummary:
|
||||
def end_session(self, session: dict[str, Any]) -> ExecutionSummary:
|
||||
"""Close the session and return normalized rein evidence."""
|
||||
|
||||
def cleanup_session(self, session: dict[str, Any]) -> None:
|
||||
"""Remove rein-owned ephemeral material after any terminal path."""
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ def run_execution(
|
|||
|
||||
manager = manager or SandboxManager()
|
||||
status = None
|
||||
session = None
|
||||
try:
|
||||
try:
|
||||
status = manager.create(
|
||||
|
|
@ -128,7 +129,6 @@ def run_execution(
|
|||
{
|
||||
"title": request.title,
|
||||
"description": request.description,
|
||||
"target_repo": request.repo,
|
||||
"request_id": request_id,
|
||||
},
|
||||
sandbox,
|
||||
|
|
@ -165,6 +165,14 @@ def run_execution(
|
|||
# return normalized evidence rather than leaking a provider exception.
|
||||
pass
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
selected_rein.cleanup_session(session)
|
||||
except Exception as exc:
|
||||
if outcome == "succeeded" or not error:
|
||||
outcome = "failed"
|
||||
failure_stage = "teardown"
|
||||
error = str(exc)
|
||||
if status is not None:
|
||||
try:
|
||||
manager.destroy(status.sandbox_id)
|
||||
|
|
|
|||
|
|
@ -3,31 +3,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def git_head(repo: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(Path(repo).expanduser()), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout.strip() if proc.returncode == 0 else ""
|
||||
|
||||
|
||||
def write_task_file(title: str, description: str, target_repo: str, **extra: Any) -> str:
|
||||
task_spec = {"title": title, "description": description, "target_repo": target_repo, **extra}
|
||||
fd = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", prefix="glas-harness-task-", delete=False
|
||||
)
|
||||
json.dump(task_spec, fd)
|
||||
fd.close()
|
||||
return fd.name
|
||||
|
||||
|
||||
def parse_json_object(text: str) -> dict[str, Any]:
|
||||
"""Parse a rein CLI's final JSON object from otherwise human-readable output."""
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ the contract itself).
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from glas_harness.contract import (
|
||||
|
|
@ -33,7 +31,8 @@ from glas_harness.contract import (
|
|||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from glas_harness.reins._shared import git_head, parse_json_object, write_task_file
|
||||
from glas_harness.reins._shared import parse_json_object
|
||||
from glas_harness.transport import ExecutionTransport, transport_from_sandbox
|
||||
|
||||
|
||||
class ReinAharnessNotInstalled(RuntimeError):
|
||||
|
|
@ -56,43 +55,43 @@ class ReinAharness(Rein):
|
|||
self.budget_tokens = budget_tokens
|
||||
self._last_result: dict[str, Any] = {}
|
||||
|
||||
def _bin(self) -> str:
|
||||
resolved = shutil.which(self.cli_bin)
|
||||
if not resolved:
|
||||
raise ReinAharnessNotInstalled(
|
||||
f"'{self.cli_bin}' not found on PATH — install rein-aharness first"
|
||||
)
|
||||
return resolved
|
||||
def _bin(self, transport: ExecutionTransport) -> str:
|
||||
try:
|
||||
return transport.resolve_executable(self.cli_bin)
|
||||
except RuntimeError as exc:
|
||||
raise ReinAharnessNotInstalled(str(exc)) from exc
|
||||
|
||||
def start_session(
|
||||
self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle
|
||||
) -> dict[str, str]:
|
||||
target_repo = (
|
||||
inputs.get("target_repo")
|
||||
or sandbox.reachability.get("workspace_dir")
|
||||
or sandbox.reachability.get("remote_dir")
|
||||
)
|
||||
if not target_repo:
|
||||
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
|
||||
|
||||
task_file = inputs.get("task_file") or write_task_file(
|
||||
inputs["title"],
|
||||
inputs["description"],
|
||||
target_repo,
|
||||
agent=inputs.get("agent", "coach"),
|
||||
timeout_seconds=profile.limits.timeout_seconds or 600,
|
||||
) -> dict[str, Any]:
|
||||
transport = transport_from_sandbox(sandbox)
|
||||
target_repo = transport.workspace
|
||||
head_before = transport.git_head()
|
||||
generated_task_file = not bool(inputs.get("task_file"))
|
||||
task_file = inputs.get("task_file") or transport.write_task_file(
|
||||
{
|
||||
"title": inputs["title"],
|
||||
"description": inputs["description"],
|
||||
"target_repo": target_repo,
|
||||
"agent": inputs.get("agent", "coach"),
|
||||
"timeout_seconds": profile.limits.timeout_seconds or 600,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox.sandbox_id,
|
||||
"transport": transport,
|
||||
"task_file": task_file,
|
||||
"generated_task_file": generated_task_file,
|
||||
"target_repo": target_repo,
|
||||
"head_before": git_head(target_repo),
|
||||
"head_before": head_before,
|
||||
"timeout_seconds": profile.limits.timeout_seconds or 600,
|
||||
}
|
||||
|
||||
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
||||
def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult:
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
argv = [
|
||||
self._bin(),
|
||||
self._bin(transport),
|
||||
"run",
|
||||
"--task-file",
|
||||
session["task_file"],
|
||||
|
|
@ -107,7 +106,7 @@ class ReinAharness(Rein):
|
|||
argv += ["--tool-profile", self.tool_profile]
|
||||
if self.budget_tokens:
|
||||
argv += ["--budget-tokens", str(self.budget_tokens)]
|
||||
proc = subprocess.run(argv, capture_output=True, text=True)
|
||||
proc = transport.run(argv, timeout=session["timeout_seconds"])
|
||||
ok = proc.returncode == 0
|
||||
output, events = self._split_stream_events(proc.stdout)
|
||||
self._last_result = parse_json_object(output)
|
||||
|
|
@ -146,8 +145,9 @@ class ReinAharness(Rein):
|
|||
remaining_lines.append(line)
|
||||
return "\n".join(remaining_lines), events
|
||||
|
||||
def end_session(self, session: dict[str, str]) -> ExecutionSummary:
|
||||
head_after = git_head(session["target_repo"])
|
||||
def end_session(self, session: dict[str, Any]) -> ExecutionSummary:
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
head_after = transport.git_head()
|
||||
committed = bool(head_after) and head_after != session.get("head_before")
|
||||
reported_ok = bool(self._last_result.get("ok", committed))
|
||||
reason = self._last_result.get("reason") or None
|
||||
|
|
@ -168,3 +168,8 @@ class ReinAharness(Rein):
|
|||
"persona_source": self._last_result.get("persona_source"),
|
||||
},
|
||||
)
|
||||
|
||||
def cleanup_session(self, session: dict[str, Any]) -> None:
|
||||
if session.get("generated_task_file") and session.get("task_file"):
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
transport.remove_file(session["task_file"])
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ run into a single call.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from glas_harness.contract import (
|
||||
|
|
@ -21,7 +19,8 @@ from glas_harness.contract import (
|
|||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from glas_harness.reins._shared import git_head, parse_json_object, write_task_file
|
||||
from glas_harness.reins._shared import parse_json_object
|
||||
from glas_harness.transport import ExecutionTransport, transport_from_sandbox
|
||||
|
||||
|
||||
class ReinOpenWeightsNotInstalled(RuntimeError):
|
||||
|
|
@ -44,41 +43,47 @@ class ReinOpenWeights(Rein):
|
|||
self.tool_profile = tool_profile
|
||||
self._last_result: dict[str, Any] = {}
|
||||
|
||||
def _bin(self) -> str:
|
||||
resolved = shutil.which(self.cli_bin)
|
||||
if not resolved:
|
||||
raise ReinOpenWeightsNotInstalled(
|
||||
f"'{self.cli_bin}' not found on PATH — install rein-openweights first"
|
||||
)
|
||||
return resolved
|
||||
def _bin(self, transport: ExecutionTransport) -> str:
|
||||
try:
|
||||
return transport.resolve_executable(self.cli_bin)
|
||||
except RuntimeError as exc:
|
||||
raise ReinOpenWeightsNotInstalled(str(exc)) from exc
|
||||
|
||||
def start_session(
|
||||
self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle
|
||||
) -> dict[str, str]:
|
||||
target_repo = (
|
||||
inputs.get("target_repo")
|
||||
or sandbox.reachability.get("workspace_dir")
|
||||
or sandbox.reachability.get("remote_dir")
|
||||
)
|
||||
if not target_repo:
|
||||
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
|
||||
|
||||
task_file = inputs.get("task_file") or write_task_file(
|
||||
inputs["title"],
|
||||
inputs["description"],
|
||||
target_repo,
|
||||
timeout_seconds=profile.limits.timeout_seconds or 600,
|
||||
) -> dict[str, Any]:
|
||||
transport = transport_from_sandbox(sandbox)
|
||||
target_repo = transport.workspace
|
||||
head_before = transport.git_head()
|
||||
generated_task_file = not bool(inputs.get("task_file"))
|
||||
task_file = inputs.get("task_file") or transport.write_task_file(
|
||||
{
|
||||
"title": inputs["title"],
|
||||
"description": inputs["description"],
|
||||
"target_repo": target_repo,
|
||||
"timeout_seconds": profile.limits.timeout_seconds or 600,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox.sandbox_id,
|
||||
"transport": transport,
|
||||
"task_file": task_file,
|
||||
"generated_task_file": generated_task_file,
|
||||
"target_repo": target_repo,
|
||||
"head_before": git_head(target_repo),
|
||||
"head_before": head_before,
|
||||
"timeout_seconds": profile.limits.timeout_seconds or 600,
|
||||
}
|
||||
|
||||
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
||||
argv = [self._bin(), "run", "--task-file", session["task_file"], "--no-hub"]
|
||||
def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult:
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
argv = [
|
||||
self._bin(transport),
|
||||
"run",
|
||||
"--task-file",
|
||||
session["task_file"],
|
||||
"--no-hub",
|
||||
]
|
||||
if self.model:
|
||||
argv += ["--model", self.model]
|
||||
if self.max_turns:
|
||||
|
|
@ -87,7 +92,7 @@ class ReinOpenWeights(Rein):
|
|||
argv += ["--budget-tokens", str(self.budget_tokens)]
|
||||
if self.tool_profile:
|
||||
argv += ["--tool-profile", self.tool_profile]
|
||||
proc = subprocess.run(argv, capture_output=True, text=True)
|
||||
proc = transport.run(argv, timeout=session["timeout_seconds"])
|
||||
ok = proc.returncode == 0
|
||||
self._last_result = parse_json_object(proc.stdout)
|
||||
return ToolResult(
|
||||
|
|
@ -104,8 +109,9 @@ class ReinOpenWeights(Rein):
|
|||
},
|
||||
)
|
||||
|
||||
def end_session(self, session: dict[str, str]) -> ExecutionSummary:
|
||||
head_after = git_head(session["target_repo"])
|
||||
def end_session(self, session: dict[str, Any]) -> ExecutionSummary:
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
head_after = transport.git_head()
|
||||
committed = bool(head_after) and head_after != session.get("head_before")
|
||||
reported_ok = bool(self._last_result.get("ok", committed))
|
||||
reason = self._last_result.get("reason") or None
|
||||
|
|
@ -126,3 +132,8 @@ class ReinOpenWeights(Rein):
|
|||
"tool_profile": self._last_result.get("tool_profile") or self.tool_profile,
|
||||
},
|
||||
)
|
||||
|
||||
def cleanup_session(self, session: dict[str, Any]) -> None:
|
||||
if session.get("generated_task_file") and session.get("task_file"):
|
||||
transport: ExecutionTransport = session["transport"]
|
||||
transport.remove_file(session["task_file"])
|
||||
|
|
|
|||
184
src/glas_harness/transport.py
Normal file
184
src/glas_harness/transport.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Execution transports derived from sand-boxer reachability descriptors.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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 glas_harness.contract import SandboxHandle
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""The sandbox reachability descriptor cannot safely execute commands."""
|
||||
|
||||
|
||||
_SSH_TARGET = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTransport:
|
||||
kind: Literal["local_namespace", "ssh"]
|
||||
workspace: str
|
||||
pid: int | None = None
|
||||
ssh_target: str | None = None
|
||||
|
||||
def command(self, argv: Sequence[str]) -> list[str]:
|
||||
if not argv:
|
||||
raise TransportError("cannot execute an empty command")
|
||||
command = [str(item) for item in argv]
|
||||
scoped_command = [
|
||||
"sh",
|
||||
"-c",
|
||||
'cd "$1" && shift && exec "$@"',
|
||||
"sh",
|
||||
self.workspace,
|
||||
*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,
|
||||
]
|
||||
if self.kind == "ssh":
|
||||
if not self.ssh_target:
|
||||
raise TransportError("SSH transport is missing target")
|
||||
return ["ssh", self.ssh_target, shlex.join(scoped_command)]
|
||||
raise TransportError(f"unsupported execution transport: {self.kind}")
|
||||
|
||||
def run(
|
||||
self,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
self.command(argv),
|
||||
input=input_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def resolve_executable(self, name: str, *, timeout: float = 15.0) -> str:
|
||||
if not name or "/" in name:
|
||||
raise TransportError("rein executable must be a command name, not a host path")
|
||||
proc = self.run(
|
||||
["sh", "-c", 'command -v -- "$1"', "sh", name], timeout=timeout
|
||||
)
|
||||
resolved = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not resolved:
|
||||
raise TransportError(
|
||||
f"'{name}' is not installed inside the selected sandbox transport"
|
||||
)
|
||||
return resolved
|
||||
|
||||
def git_head(self, *, timeout: float = 15.0) -> str:
|
||||
proc = self.run(
|
||||
["git", "-C", self.workspace, "rev-parse", "HEAD"], timeout=timeout
|
||||
)
|
||||
head = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not head:
|
||||
raise TransportError(
|
||||
"cannot read git HEAD inside the selected sandbox transport"
|
||||
)
|
||||
return head
|
||||
|
||||
def write_task_file(self, payload: dict[str, Any]) -> str:
|
||||
task_name = f".glas-harness-task-{uuid.uuid4().hex}.json"
|
||||
# Keep the task spec under Git's private metadata directory so an
|
||||
# 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"'
|
||||
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: "
|
||||
+ (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: "
|
||||
+ (proc.stderr.strip() or f"exit {proc.returncode}")
|
||||
)
|
||||
|
||||
|
||||
def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
|
||||
reachability = sandbox.reachability
|
||||
pid = reachability.get("pid")
|
||||
workspace = reachability.get("workspace_dir")
|
||||
ssh_target = reachability.get("ssh")
|
||||
remote_dir = reachability.get("remote_dir")
|
||||
|
||||
has_local = pid is not None or workspace is not None
|
||||
has_remote = ssh_target is not None or remote_dir is not None
|
||||
if has_local and has_remote:
|
||||
raise TransportError("ambiguous sandbox reachability: local and SSH fields coexist")
|
||||
if has_local:
|
||||
if not pid or not workspace:
|
||||
raise TransportError(
|
||||
"incomplete local sandbox reachability: pid and workspace_dir are required"
|
||||
)
|
||||
try:
|
||||
parsed_pid = int(str(pid))
|
||||
except ValueError as exc:
|
||||
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")
|
||||
return ExecutionTransport(
|
||||
kind="local_namespace", workspace=str(workspace), pid=parsed_pid
|
||||
)
|
||||
if has_remote:
|
||||
if not ssh_target or not remote_dir:
|
||||
raise TransportError(
|
||||
"incomplete remote sandbox reachability: ssh and remote_dir are required"
|
||||
)
|
||||
if not _SSH_TARGET.fullmatch(str(ssh_target)):
|
||||
raise TransportError("SSH reachability must be a single host or user@host target")
|
||||
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)
|
||||
)
|
||||
raise TransportError("sandbox exposes no supported execution reachability")
|
||||
|
|
@ -26,6 +26,7 @@ class _FakeRein(Rein):
|
|||
assert str(profile.ref) == PROFILE
|
||||
assert sandbox.sandbox_id == "sbx1"
|
||||
assert sandbox.reachability.get("workspace_dir") == "/tmp/ws"
|
||||
assert "target_repo" not in inputs
|
||||
return {"session": "s1"}
|
||||
|
||||
def dispatch_tool(self, session, tool_call: ToolCall) -> ToolResult:
|
||||
|
|
@ -50,6 +51,9 @@ class _FakeRein(Rein):
|
|||
resolved_model="claude-sonnet-4-6",
|
||||
)
|
||||
|
||||
def cleanup_session(self, session):
|
||||
self.calls.append("cleanup_session")
|
||||
|
||||
|
||||
def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus:
|
||||
now = datetime.now(UTC)
|
||||
|
|
@ -86,7 +90,12 @@ def test_run_execution_creates_and_destroys_sandbox() -> None:
|
|||
|
||||
result = run_execution(_request(), rein=rein, manager=manager)
|
||||
|
||||
assert rein.calls == ["start_session", "dispatch_tool", "end_session"]
|
||||
assert rein.calls == [
|
||||
"start_session",
|
||||
"dispatch_tool",
|
||||
"end_session",
|
||||
"cleanup_session",
|
||||
]
|
||||
manager.create.assert_called_once()
|
||||
manager.destroy.assert_called_once_with("sbx1")
|
||||
assert result.ok is True
|
||||
|
|
@ -106,13 +115,15 @@ def test_run_execution_normalizes_execution_failure_and_tears_down() -> None:
|
|||
def dispatch_tool(self, session, tool_call):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
result = run_execution(_request(), rein=_FailingRein(), manager=manager)
|
||||
rein = _FailingRein()
|
||||
result = run_execution(_request(), rein=rein, manager=manager)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.evidence.outcome == "failed"
|
||||
assert result.evidence.failure_stage == "execution"
|
||||
assert result.evidence.error == "execution failed; inspect direct caller error"
|
||||
assert result.tool_error == "boom"
|
||||
assert rein.calls == ["start_session", "cleanup_session"]
|
||||
manager.destroy.assert_called_once_with("sbx1")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ import pytest
|
|||
from glas_harness.contract import Rein, SandboxHandle, ToolCall
|
||||
from glas_harness.profiles import ProfileCatalog
|
||||
from glas_harness.reins.rein_aharness import ReinAharness, ReinAharnessNotInstalled
|
||||
from glas_harness.transport import ExecutionTransport, TransportError
|
||||
|
||||
|
||||
def test_is_rein_subclass() -> None:
|
||||
|
|
@ -15,8 +17,10 @@ def test_is_rein_subclass() -> None:
|
|||
|
||||
def test_bin_raises_if_not_installed() -> None:
|
||||
rein = ReinAharness(cli_bin="does-not-exist-binary")
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.resolve_executable.side_effect = TransportError("missing")
|
||||
with pytest.raises(ReinAharnessNotInstalled):
|
||||
rein._bin()
|
||||
rein._bin(transport)
|
||||
|
||||
|
||||
def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
|
||||
|
|
@ -26,24 +30,32 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
|
|||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||
|
||||
rein = ReinAharness()
|
||||
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)})
|
||||
session = rein.start_session(
|
||||
profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0],
|
||||
inputs={"title": "t", "description": "d"},
|
||||
sandbox=sandbox,
|
||||
sandbox = SandboxHandle(
|
||||
sandbox_id="abc",
|
||||
host="localhost",
|
||||
reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)},
|
||||
)
|
||||
with patch.object(ExecutionTransport, "git_head", return_value="abc123"):
|
||||
session = rein.start_session(
|
||||
profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0],
|
||||
inputs={"title": "t", "description": "d"},
|
||||
sandbox=sandbox,
|
||||
)
|
||||
|
||||
assert session["target_repo"] == str(repo)
|
||||
assert session["head_before"]
|
||||
assert session["head_before"] == "abc123"
|
||||
assert session["transport"].workspace == str(repo)
|
||||
task_spec = json.loads(open(session["task_file"]).read())
|
||||
assert task_spec["title"] == "t"
|
||||
assert task_spec["target_repo"] == str(repo)
|
||||
rein.cleanup_session(session)
|
||||
assert not os.path.exists(session["task_file"])
|
||||
|
||||
|
||||
def test_start_session_requires_resolvable_target_repo() -> None:
|
||||
rein = ReinAharness()
|
||||
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={})
|
||||
with pytest.raises(ValueError, match="target_repo"):
|
||||
with pytest.raises(RuntimeError, match="reachability"):
|
||||
rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox)
|
||||
|
||||
|
||||
|
|
@ -53,30 +65,31 @@ def test_dispatch_tool_invokes_agent_harness_cli() -> None:
|
|||
tool_profile="green-commit-only",
|
||||
budget_tokens=1234,
|
||||
)
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
|
||||
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run:
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
argv = transport.run.call_args.args[0]
|
||||
assert argv[:3] == ["/usr/bin/agent-harness", "run", "--task-file"]
|
||||
assert argv[argv.index("--model") + 1] == "claude-sonnet-4-6"
|
||||
assert argv[argv.index("--tool-profile") + 1] == "green-commit-only"
|
||||
assert argv[argv.index("--budget-tokens") + 1] == "1234"
|
||||
assert transport.run.call_args.kwargs["timeout"] == 30
|
||||
assert result.ok is True
|
||||
assert result.output == "ok"
|
||||
|
||||
|
||||
def test_dispatch_tool_reports_failure() -> None:
|
||||
rein = ReinAharness()
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=1, stdout="", stderr="boom")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
fake_result = MagicMock(returncode=1, stdout="", stderr="boom")
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
|
||||
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result):
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error == "boom"
|
||||
|
|
@ -88,14 +101,24 @@ def test_end_session_detects_new_commit(tmp_path) -> None:
|
|||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||
|
||||
from glas_harness.reins._shared import git_head
|
||||
|
||||
rein = ReinAharness()
|
||||
head_before = git_head(str(repo))
|
||||
head_before = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
|
||||
|
||||
summary = rein.end_session({"target_repo": str(repo), "head_before": head_before})
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.git_head.return_value = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
summary = rein.end_session({"transport": transport, "head_before": head_before})
|
||||
assert summary.committed is True
|
||||
assert summary.outcome == "succeeded"
|
||||
assert summary.commit_sha != head_before
|
||||
|
|
@ -107,12 +130,17 @@ def test_end_session_no_new_commit(tmp_path) -> None:
|
|||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||
|
||||
from glas_harness.reins._shared import git_head
|
||||
|
||||
rein = ReinAharness()
|
||||
head = git_head(str(repo))
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
summary = rein.end_session({"target_repo": str(repo), "head_before": head})
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.git_head.return_value = head
|
||||
summary = rein.end_session({"transport": transport, "head_before": head})
|
||||
assert summary.committed is False
|
||||
assert summary.outcome == "failed"
|
||||
|
||||
|
|
@ -121,20 +149,22 @@ def test_dispatch_tool_streams_events_when_enabled() -> None:
|
|||
import json
|
||||
|
||||
rein = ReinAharness(stream_tool_events=True)
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
stdout_lines = [
|
||||
json.dumps({"stream_event": {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}}),
|
||||
json.dumps({"stream_event": {"type": "system", "subtype": "hook_started"}}),
|
||||
json.dumps({"ok": True, "committed": True}, indent=2),
|
||||
]
|
||||
fake_result = MagicMock(returncode=0, stdout="\n".join(stdout_lines), stderr="")
|
||||
transport.run.return_value = MagicMock(
|
||||
returncode=0, stdout="\n".join(stdout_lines), stderr=""
|
||||
)
|
||||
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
|
||||
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run:
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
argv = transport.run.call_args.args[0]
|
||||
assert "--stream-tool-events" in argv
|
||||
assert len(result.events) == 2
|
||||
assert result.events[0]["message"]["content"][0]["name"] == "Read"
|
||||
|
|
@ -145,13 +175,28 @@ def test_dispatch_tool_streams_events_when_enabled() -> None:
|
|||
|
||||
def test_dispatch_tool_no_stream_flag_by_default() -> None:
|
||||
rein = ReinAharness()
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
|
||||
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run:
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
argv = transport.run.call_args.args[0]
|
||||
assert "--stream-tool-events" not in argv
|
||||
assert result.events == []
|
||||
|
||||
|
||||
def test_cleanup_session_removes_only_generated_task_file() -> None:
|
||||
rein = ReinAharness()
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
rein.cleanup_session(
|
||||
{"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": True}
|
||||
)
|
||||
transport.remove_file.assert_called_once_with("/sandbox/task.json")
|
||||
|
||||
transport.reset_mock()
|
||||
rein.cleanup_session(
|
||||
{"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": False}
|
||||
)
|
||||
transport.remove_file.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ import pytest
|
|||
from glas_harness.contract import Rein, SandboxHandle, ToolCall
|
||||
from glas_harness.profiles import ProfileCatalog
|
||||
from glas_harness.reins.rein_openweights import ReinOpenWeights, ReinOpenWeightsNotInstalled
|
||||
from glas_harness.transport import ExecutionTransport, TransportError
|
||||
|
||||
|
||||
def test_is_rein_subclass() -> None:
|
||||
|
|
@ -15,8 +17,10 @@ def test_is_rein_subclass() -> None:
|
|||
|
||||
def test_bin_raises_if_not_installed() -> None:
|
||||
rein = ReinOpenWeights(cli_bin="does-not-exist-binary")
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.resolve_executable.side_effect = TransportError("missing")
|
||||
with pytest.raises(ReinOpenWeightsNotInstalled):
|
||||
rein._bin()
|
||||
rein._bin(transport)
|
||||
|
||||
|
||||
def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
|
||||
|
|
@ -26,37 +30,45 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
|
|||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||
|
||||
rein = ReinOpenWeights()
|
||||
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)})
|
||||
session = rein.start_session(
|
||||
profile=ProfileCatalog().resolve("harness.agent-dev-openweights-local@1.0.0")[0],
|
||||
inputs={"title": "t", "description": "d"},
|
||||
sandbox=sandbox,
|
||||
sandbox = SandboxHandle(
|
||||
sandbox_id="abc",
|
||||
host="localhost",
|
||||
reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)},
|
||||
)
|
||||
with patch.object(ExecutionTransport, "git_head", return_value="abc123"):
|
||||
session = rein.start_session(
|
||||
profile=ProfileCatalog().resolve("harness.agent-dev-openweights-local@1.0.0")[0],
|
||||
inputs={"title": "t", "description": "d"},
|
||||
sandbox=sandbox,
|
||||
)
|
||||
|
||||
assert session["target_repo"] == str(repo)
|
||||
assert session["head_before"]
|
||||
assert session["head_before"] == "abc123"
|
||||
assert session["transport"].workspace == str(repo)
|
||||
task_spec = json.loads(open(session["task_file"]).read())
|
||||
assert task_spec["title"] == "t"
|
||||
assert task_spec["target_repo"] == str(repo)
|
||||
rein.cleanup_session(session)
|
||||
assert not os.path.exists(session["task_file"])
|
||||
|
||||
|
||||
def test_start_session_requires_resolvable_target_repo() -> None:
|
||||
rein = ReinOpenWeights()
|
||||
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={})
|
||||
with pytest.raises(ValueError, match="target_repo"):
|
||||
with pytest.raises(RuntimeError, match="reachability"):
|
||||
rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox)
|
||||
|
||||
|
||||
def test_dispatch_tool_invokes_rein_openweights_cli() -> None:
|
||||
rein = ReinOpenWeights()
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run:
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
argv = transport.run.call_args.args[0]
|
||||
assert argv[:3] == ["/usr/bin/rein-openweights", "run", "--task-file"]
|
||||
assert "--no-hub" in argv
|
||||
assert result.ok is True
|
||||
|
|
@ -70,14 +82,14 @@ def test_dispatch_tool_passes_model_when_set() -> None:
|
|||
budget_tokens=1234,
|
||||
tool_profile="green-commit-only",
|
||||
)
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run:
|
||||
rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
argv = transport.run.call_args.args[0]
|
||||
assert "--model" in argv
|
||||
assert "meta-llama/llama-3.1-70b-instruct" in argv
|
||||
assert argv[argv.index("--max-turns") + 1] == "8"
|
||||
|
|
@ -87,17 +99,34 @@ def test_dispatch_tool_passes_model_when_set() -> None:
|
|||
|
||||
def test_dispatch_tool_reports_failure() -> None:
|
||||
rein = ReinOpenWeights()
|
||||
session = {"task_file": "/tmp/task.json"}
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.return_value = MagicMock(returncode=1, stdout="", stderr="boom")
|
||||
session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30}
|
||||
|
||||
fake_result = MagicMock(returncode=1, stdout="", stderr="boom")
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result):
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error == "boom"
|
||||
|
||||
|
||||
def test_dispatch_tool_enforces_outer_timeout() -> None:
|
||||
rein = ReinOpenWeights()
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.run.side_effect = subprocess.TimeoutExpired("rein-openweights", 30)
|
||||
session = {
|
||||
"task_file": "/tmp/task.json",
|
||||
"transport": transport,
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
|
||||
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||
|
||||
assert transport.run.call_args.kwargs["timeout"] == 30
|
||||
|
||||
|
||||
def test_end_session_detects_new_commit(tmp_path) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
|
|
@ -105,12 +134,31 @@ def test_end_session_detects_new_commit(tmp_path) -> None:
|
|||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||
|
||||
rein = ReinOpenWeights()
|
||||
from glas_harness.reins._shared import git_head
|
||||
|
||||
head_before = git_head(str(repo))
|
||||
head_before = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
|
||||
|
||||
summary = rein.end_session({"target_repo": str(repo), "head_before": head_before})
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
transport.git_head.return_value = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
summary = rein.end_session({"transport": transport, "head_before": head_before})
|
||||
assert summary.committed is True
|
||||
assert summary.outcome == "succeeded"
|
||||
assert summary.commit_sha != head_before
|
||||
|
||||
|
||||
def test_cleanup_session_removes_generated_task_file() -> None:
|
||||
rein = ReinOpenWeights()
|
||||
transport = MagicMock(spec=ExecutionTransport)
|
||||
rein.cleanup_session(
|
||||
{"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": True}
|
||||
)
|
||||
transport.remove_file.assert_called_once_with("/sandbox/task.json")
|
||||
|
|
|
|||
135
tests/test_transport.py
Normal file
135
tests/test_transport.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from glas_harness.contract import SandboxHandle
|
||||
from glas_harness.transport import ExecutionTransport, TransportError, transport_from_sandbox
|
||||
|
||||
|
||||
def test_local_namespace_transport_requires_pid_and_workspace(tmp_path: Path) -> None:
|
||||
sandbox = SandboxHandle(
|
||||
sandbox_id="sbx",
|
||||
host="localhost",
|
||||
reachability={"pid": "4321", "workspace_dir": str(tmp_path)},
|
||||
)
|
||||
transport = transport_from_sandbox(sandbox)
|
||||
|
||||
assert transport.kind == "local_namespace"
|
||||
assert transport.workspace == str(tmp_path)
|
||||
assert transport.command(["git", "status"]) == [
|
||||
"nsenter",
|
||||
"--target",
|
||||
"4321",
|
||||
"--mount",
|
||||
"--pid",
|
||||
"--net",
|
||||
"--uts",
|
||||
"--ipc",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
'cd "$1" && shift && exec "$@"',
|
||||
"sh",
|
||||
str(tmp_path),
|
||||
"git",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
||||
def test_remote_transport_wraps_command_without_local_shell() -> None:
|
||||
sandbox = SandboxHandle(
|
||||
sandbox_id="sbx",
|
||||
host="sandboxer01",
|
||||
reachability={"ssh": "agent@sandboxer01", "remote_dir": "/tmp/sbx"},
|
||||
)
|
||||
transport = transport_from_sandbox(sandbox)
|
||||
|
||||
assert transport.kind == "ssh"
|
||||
assert transport.command(["git", "-C", "/tmp/sbx", "status"]) == [
|
||||
"ssh",
|
||||
"agent@sandboxer01",
|
||||
"sh -c 'cd \"$1\" && shift && exec \"$@\"' sh /tmp/sbx git -C /tmp/sbx status",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reachability",
|
||||
[
|
||||
{},
|
||||
{"workspace_dir": "/tmp/ws"},
|
||||
{"pid": "12"},
|
||||
{"ssh": "agent@host"},
|
||||
{"remote_dir": "/tmp/ws"},
|
||||
{
|
||||
"pid": "12",
|
||||
"workspace_dir": "/tmp/ws",
|
||||
"ssh": "agent@host",
|
||||
"remote_dir": "/tmp/ws",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_incomplete_or_ambiguous_reachability_fails_closed(reachability) -> None:
|
||||
with pytest.raises(TransportError):
|
||||
transport_from_sandbox(
|
||||
SandboxHandle(sandbox_id="sbx", host="localhost", reachability=reachability)
|
||||
)
|
||||
|
||||
|
||||
def test_local_task_file_is_private_and_removable(tmp_path: Path) -> None:
|
||||
(tmp_path / ".git").mkdir()
|
||||
transport = ExecutionTransport(
|
||||
kind="local_namespace", workspace=str(tmp_path), pid=123
|
||||
)
|
||||
task_path = transport.write_task_file({"title": "bounded", "description": "safe"})
|
||||
path = Path(task_path)
|
||||
|
||||
assert path.parent == tmp_path / ".git"
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert json.loads(path.read_text())["title"] == "bounded"
|
||||
|
||||
transport.remove_file(task_path)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_remote_task_file_uses_ssh_stdin_and_cleanup() -> None:
|
||||
transport = ExecutionTransport(
|
||||
kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01"
|
||||
)
|
||||
completed = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("glas_harness.transport.subprocess.run", return_value=completed) as run:
|
||||
task_path = transport.write_task_file({"title": "bounded"})
|
||||
transport.remove_file(task_path)
|
||||
|
||||
create = run.call_args_list[0]
|
||||
assert create.args[0][:2] == ["ssh", "agent@sandboxer01"]
|
||||
assert create.kwargs["input"] == '{"title": "bounded"}'
|
||||
assert "cat >" in create.args[0][2]
|
||||
assert "rm -f" in run.call_args_list[1].args[0][2]
|
||||
|
||||
|
||||
def test_resolve_executable_fails_inside_transport() -> None:
|
||||
transport = ExecutionTransport(
|
||||
kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01"
|
||||
)
|
||||
with patch.object(
|
||||
ExecutionTransport,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=127, stdout="", stderr="not found"),
|
||||
):
|
||||
with pytest.raises(TransportError, match="not installed inside"):
|
||||
transport.resolve_executable("rein-aharness")
|
||||
|
||||
|
||||
def test_git_head_fails_closed_on_transport_error() -> None:
|
||||
transport = ExecutionTransport(
|
||||
kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01"
|
||||
)
|
||||
with patch.object(
|
||||
ExecutionTransport,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stdout="", stderr="permission denied"),
|
||||
):
|
||||
with pytest.raises(TransportError, match="inside the selected sandbox"):
|
||||
transport.git_head()
|
||||
156
workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md
Normal file
156
workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
id: GLAS-WP-0005
|
||||
type: workplan
|
||||
title: "Sandbox-boundary correction and runtime hardening"
|
||||
domain: infotech
|
||||
repo: glas-harness
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: sandbox-boundary-runtime-hardening
|
||||
created: "2026-08-21"
|
||||
updated: "2026-08-21"
|
||||
---
|
||||
|
||||
# Sandbox-boundary correction and runtime hardening
|
||||
|
||||
## Context
|
||||
|
||||
The versioned profile path provisions and tears down sand-boxer instances, but
|
||||
the concrete rein adapters currently prefer the caller's original repository
|
||||
path and invoke their CLIs as ordinary local subprocesses. They do not enter a
|
||||
same-host bwrap namespace or cross the SSH reachability boundary for a remote
|
||||
sandbox. As a result, the existing lifecycle evidence does not prove that the
|
||||
agent process executed inside the selected sandbox, and the enabled remote
|
||||
profile is not executable through its declared transport.
|
||||
|
||||
The review also found persistent task-spec files in `/tmp`, no outer subprocess
|
||||
timeout, an undeclared direct `httpx` dependency, and a Forgejo workflow that
|
||||
only prints routing probes rather than running tests or catalog validation.
|
||||
|
||||
## Goal
|
||||
|
||||
Make every governed execution use the workspace and execution transport
|
||||
declared by the resolved sand-boxer reachability descriptor, fail closed when
|
||||
that descriptor is unsupported, and close the adjacent runtime/CI gaps without
|
||||
changing scheduling, rein credential ownership, or sand-boxer provisioning
|
||||
ownership.
|
||||
|
||||
## Task: Correct workspace and execution transport selection
|
||||
|
||||
```task
|
||||
id: GLAS-WP-0005-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Introduce one validated execution transport derived from `SandboxHandle`:
|
||||
|
||||
- same-host bwrap uses `workspace_dir` and enters the namespace identified by
|
||||
`pid` for the rein subprocess;
|
||||
- remote sandboxes use `remote_dir` and the declared SSH endpoint;
|
||||
- the caller's source checkout is never passed to the rein as its execution
|
||||
workspace after sandbox creation; and
|
||||
- missing, ambiguous, or unsupported reachability fails closed before the rein
|
||||
subprocess starts.
|
||||
|
||||
Add tests proving the original checkout is not selected, the expected transport
|
||||
argv is used, remote execution is represented honestly, and teardown still
|
||||
runs on every failure.
|
||||
|
||||
**Completed 2026-08-21:** added strict reachability-derived execution
|
||||
transports. Local descriptors require `pid` + `workspace_dir` and wrap commands
|
||||
with `nsenter`; remote descriptors require `ssh` + `remote_dir` and wrap
|
||||
commands over SSH. Mixed/incomplete descriptors fail closed. The gateway no
|
||||
longer passes the source checkout to a rein after provisioning, and tests cover
|
||||
transport argv, workspace selection, refusal, cleanup, and teardown.
|
||||
|
||||
## Task: Bound subprocesses and remove temporary task material
|
||||
|
||||
```task
|
||||
id: GLAS-WP-0005-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Enforce the profile timeout on the outer rein subprocess, normalize timeout
|
||||
failures, and remove generated task-spec files on success, ordinary failure,
|
||||
timeout, and interruption. Caller-supplied task files must not be deleted.
|
||||
Record and safely remove already-orphaned `glas-harness-task-*.json` files after
|
||||
their exact scope is verified.
|
||||
|
||||
**Completed 2026-08-21:** the profile timeout now bounds the outer transport
|
||||
subprocess. Generated task specs are mode-0600 files inside the sandbox
|
||||
workspace and the gateway calls rein cleanup before sandbox teardown on every
|
||||
terminal path; caller-owned task files are preserved. Verified all 22 legacy
|
||||
`/tmp/glas-harness-task-*.json` files were owned by the current user, mode 0600,
|
||||
and valid Glas task specs with no active rein process, then securely removed
|
||||
those exact files.
|
||||
|
||||
## Task: Repair package dependency metadata
|
||||
|
||||
```task
|
||||
id: GLAS-WP-0005-T03
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Declare direct runtime dependencies directly, build the wheel, install it in an
|
||||
isolated environment with the sandbox extra, and validate that packaged profile
|
||||
and rein data resolve without relying on the source checkout.
|
||||
|
||||
**Completed 2026-08-21:** declared `httpx>=0.27` directly. Built the wheel with
|
||||
uv, installed it outside the checkout, resolved all three profiles from packaged
|
||||
data, verified both rein descriptors are present, and inspected wheel metadata
|
||||
for direct and optional dependencies.
|
||||
|
||||
## Task: Replace routing-only CI with executable validation
|
||||
|
||||
```task
|
||||
id: GLAS-WP-0005-T04
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
Make Forgejo CI check out the repository, install the project and its test
|
||||
dependencies, run the full unit suite, and run `glas-harness profiles`. Retain
|
||||
only routing probes that still provide operational value.
|
||||
|
||||
**Implemented 2026-08-21:** replaced the two echo-only jobs with the estate's
|
||||
archive-checkout pattern, explicit Python/sand-boxer/project installation, the
|
||||
full test suite, and packaged catalog validation. Closure waits on the first
|
||||
post-push Forgejo run.
|
||||
|
||||
## Task: Re-prove the sandbox boundary and close evidence
|
||||
|
||||
```task
|
||||
id: GLAS-WP-0005-T05
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
Run offline boundary tests and, where the already-approved local runtime permits
|
||||
it, a bounded non-secret bwrap proof showing that execution targets the sandbox
|
||||
copy and cannot mutate the source checkout. Correct prior evidence claims rather
|
||||
than silently treating lifecycle-only proof as isolation proof. Run the full
|
||||
suite and catalog validation, synchronize work records, and hand off any live
|
||||
residual before finishing the workplan.
|
||||
|
||||
**2026-08-21 boundary recheck:** offline transport/package tests pass and the
|
||||
prior GLAS-WP-0004 evidence now explicitly withdraws its isolation implication.
|
||||
A real managed-agent bwrap probe failed closed: direct `nsenter` was denied on
|
||||
the reported namespace and neither rein command is mounted inside the bwrap
|
||||
root. The source checkout is no longer used as a fallback. Runtime mounting,
|
||||
owner-exec, network, and credential delivery are handed off as residual
|
||||
`GLAS-IN-0002`; live positive proof waits on that owner contract.
|
||||
|
||||
## Overall acceptance
|
||||
|
||||
1. No governed rein invocation operates on the caller's original checkout.
|
||||
2. The selected process crosses the declared local namespace or remote SSH
|
||||
boundary, or fails closed without execution.
|
||||
3. Profile timeouts bound the outer subprocess and generated task files do not
|
||||
survive any terminal path.
|
||||
4. Package metadata names every direct dependency and the built artifact carries
|
||||
the executable profile/rein catalog.
|
||||
5. Forgejo CI executes tests plus catalog validation.
|
||||
6. Evidence distinguishes sandbox lifecycle from verified sandbox execution.
|
||||
Loading…
Add table
Add a link
Reference in a new issue