openshell-agent-runner 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openshell_agent_runner/__init__.py +4 -0
- openshell_agent_runner/artifacts.py +77 -0
- openshell_agent_runner/cli.py +293 -0
- openshell_agent_runner/config.py +382 -0
- openshell_agent_runner/errors.py +20 -0
- openshell_agent_runner/harnesses/__init__.py +4 -0
- openshell_agent_runner/harnesses/pi/__init__.py +4 -0
- openshell_agent_runner/harnesses/pi/resources.py +91 -0
- openshell_agent_runner/harnesses/pi/runtime/extensions/submit-result.ts +66 -0
- openshell_agent_runner/harnesses/pi/runtime/image/Dockerfile +28 -0
- openshell_agent_runner/harnesses/pi/runtime/image/exec.sh +68 -0
- openshell_agent_runner/harnesses/resources.py +17 -0
- openshell_agent_runner/openshell.py +162 -0
- openshell_agent_runner/profile_init.py +104 -0
- openshell_agent_runner/profiles/__init__.py +4 -0
- openshell_agent_runner/profiles/reviewer/models.json +19 -0
- openshell_agent_runner/profiles/reviewer/policy.yaml +15 -0
- openshell_agent_runner/profiles/reviewer/profile.yaml +12 -0
- openshell_agent_runner/profiles/reviewer/prompt.md +5 -0
- openshell_agent_runner/profiles/reviewer/settings.json +5 -0
- openshell_agent_runner/runner.py +277 -0
- openshell_agent_runner-0.0.1.dist-info/METADATA +404 -0
- openshell_agent_runner-0.0.1.dist-info/RECORD +26 -0
- openshell_agent_runner-0.0.1.dist-info/WHEEL +4 -0
- openshell_agent_runner-0.0.1.dist-info/entry_points.txt +3 -0
- openshell_agent_runner-0.0.1.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Build, execute, and inspect native OpenShell commands."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import resource
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import partial
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from openshell_agent_runner.artifacts import ARTIFACT_PATH
|
|
19
|
+
from openshell_agent_runner.errors import ExecutionError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from openshell_agent_runner.harnesses.resources import PreparedResources
|
|
23
|
+
from openshell_agent_runner.runner import ResolvedRun, RunRequest
|
|
24
|
+
|
|
25
|
+
MINIMUM_OPEN_SHELL_VERSION = (0, 0, 111)
|
|
26
|
+
RESERVED_LABEL = "oar-run-id"
|
|
27
|
+
VERSION_PATTERN = re.compile(r"\b(\d+)\.(\d+)\.(\d+)\b")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class NativeTarget:
|
|
32
|
+
executable: str = "openshell"
|
|
33
|
+
gateway: str | None = None
|
|
34
|
+
workspace: str = "default"
|
|
35
|
+
|
|
36
|
+
def global_args(self) -> list[str]:
|
|
37
|
+
values: list[str] = []
|
|
38
|
+
if self.gateway:
|
|
39
|
+
values.extend(["--gateway", self.gateway])
|
|
40
|
+
values.extend(["--workspace", self.workspace])
|
|
41
|
+
return values
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sandbox_create(
|
|
45
|
+
resolved: ResolvedRun,
|
|
46
|
+
resources: PreparedResources,
|
|
47
|
+
name: str,
|
|
48
|
+
token: str,
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
command = [*resolved.create_command, "--name", name]
|
|
51
|
+
for upload in resources.uploads:
|
|
52
|
+
command.extend(["--upload", upload])
|
|
53
|
+
command.extend(["--label", f"{RESERVED_LABEL}={token}"])
|
|
54
|
+
command.extend(["--", "bash", "/opt/oar/pi/exec.sh", *resources.arguments])
|
|
55
|
+
return command
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sandbox_download(resolved: ResolvedRun, name: str, destination: Path) -> list[str]:
|
|
59
|
+
return [
|
|
60
|
+
resolved.request.openshell_bin,
|
|
61
|
+
"sandbox",
|
|
62
|
+
"download",
|
|
63
|
+
name,
|
|
64
|
+
ARTIFACT_PATH,
|
|
65
|
+
str(destination),
|
|
66
|
+
*_native_target_args(resolved.request),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def sandbox_get(request: RunRequest, name: str) -> list[str]:
|
|
71
|
+
return [
|
|
72
|
+
request.openshell_bin,
|
|
73
|
+
"sandbox",
|
|
74
|
+
"get",
|
|
75
|
+
name,
|
|
76
|
+
*_native_target_args(request),
|
|
77
|
+
"--output",
|
|
78
|
+
"json",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sandbox_delete(request: RunRequest, name: str) -> list[str]:
|
|
83
|
+
return [
|
|
84
|
+
request.openshell_bin,
|
|
85
|
+
"sandbox",
|
|
86
|
+
"delete",
|
|
87
|
+
name,
|
|
88
|
+
*_native_target_args(request),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run(
|
|
93
|
+
command: list[str],
|
|
94
|
+
timeout: int,
|
|
95
|
+
*,
|
|
96
|
+
capture: bool = False,
|
|
97
|
+
max_file_bytes: int | None = None,
|
|
98
|
+
) -> subprocess.CompletedProcess[str]:
|
|
99
|
+
try:
|
|
100
|
+
return subprocess.run(
|
|
101
|
+
command,
|
|
102
|
+
check=True,
|
|
103
|
+
text=True,
|
|
104
|
+
capture_output=capture,
|
|
105
|
+
timeout=timeout,
|
|
106
|
+
preexec_fn=(
|
|
107
|
+
partial(_set_file_size_limit, max_file_bytes)
|
|
108
|
+
if max_file_bytes is not None
|
|
109
|
+
else None
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
113
|
+
raise ExecutionError(
|
|
114
|
+
f"command failed: {shlex.join(command)}: {error}"
|
|
115
|
+
) from error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _set_file_size_limit(max_file_bytes: int) -> None:
|
|
119
|
+
resource.setrlimit(resource.RLIMIT_FSIZE, (max_file_bytes, max_file_bytes))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def doctor(target: NativeTarget) -> list[tuple[str, str]]:
|
|
123
|
+
checks = []
|
|
124
|
+
for name, arguments in (
|
|
125
|
+
("version", ["--version"]),
|
|
126
|
+
("status", ["status"]),
|
|
127
|
+
("inference", ["inference", "get"]),
|
|
128
|
+
):
|
|
129
|
+
completed = _run_read_only(target, arguments)
|
|
130
|
+
result = completed.stdout.strip()
|
|
131
|
+
if name == "version":
|
|
132
|
+
match = VERSION_PATTERN.search(result)
|
|
133
|
+
if match is None:
|
|
134
|
+
raise ExecutionError(f"cannot parse OpenShell version: {result!r}")
|
|
135
|
+
version = tuple(int(part) for part in match.groups())
|
|
136
|
+
if version < MINIMUM_OPEN_SHELL_VERSION:
|
|
137
|
+
minimum = ".".join(str(part) for part in MINIMUM_OPEN_SHELL_VERSION)
|
|
138
|
+
raise ExecutionError(
|
|
139
|
+
f"OpenShell {minimum} or newer is required; found {match.group(0)}"
|
|
140
|
+
)
|
|
141
|
+
checks.append((name, result))
|
|
142
|
+
return checks
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _native_target_args(request: RunRequest) -> list[str]:
|
|
146
|
+
result: list[str] = []
|
|
147
|
+
if request.gateway:
|
|
148
|
+
result.extend(["--gateway", request.gateway])
|
|
149
|
+
result.extend(["--workspace", request.workspace])
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run_read_only(
|
|
154
|
+
target: NativeTarget, arguments: Sequence[str]
|
|
155
|
+
) -> subprocess.CompletedProcess[str]:
|
|
156
|
+
command = [target.executable, *arguments, *target.global_args()]
|
|
157
|
+
try:
|
|
158
|
+
return subprocess.run(command, check=True, text=True, capture_output=True)
|
|
159
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
160
|
+
raise ExecutionError(
|
|
161
|
+
f"OpenShell check failed: {shlex.join(command)}: {error}"
|
|
162
|
+
) from error
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Create editable profiles from resources packaged with OAR."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from importlib.resources import as_file, files
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from openshell_agent_runner.config import MODEL_IDENTIFIER_PATTERN, load_profile
|
|
18
|
+
from openshell_agent_runner.errors import ConfigurationError
|
|
19
|
+
|
|
20
|
+
PACKAGED_PROFILES = ("reviewer",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ThinkingLevel(StrEnum):
|
|
24
|
+
OFF = "off"
|
|
25
|
+
MINIMAL = "minimal"
|
|
26
|
+
LOW = "low"
|
|
27
|
+
MEDIUM = "medium"
|
|
28
|
+
HIGH = "high"
|
|
29
|
+
XHIGH = "xhigh"
|
|
30
|
+
MAX = "max"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def initialize_profiles(
|
|
34
|
+
destination: Path,
|
|
35
|
+
profile_names: Sequence[str],
|
|
36
|
+
model_id: str,
|
|
37
|
+
thinking: ThinkingLevel,
|
|
38
|
+
) -> tuple[Path, ...]:
|
|
39
|
+
"""Create selected packaged profiles under destination."""
|
|
40
|
+
if not re.fullmatch(MODEL_IDENTIFIER_PATTERN, model_id):
|
|
41
|
+
raise ConfigurationError("--model must be a valid model identifier")
|
|
42
|
+
|
|
43
|
+
selected = tuple(profile_names) or PACKAGED_PROFILES
|
|
44
|
+
if len(selected) != len(set(selected)):
|
|
45
|
+
raise ConfigurationError("--profile values must be unique")
|
|
46
|
+
unknown = sorted(set(selected) - set(PACKAGED_PROFILES))
|
|
47
|
+
if unknown:
|
|
48
|
+
available = ", ".join(PACKAGED_PROFILES)
|
|
49
|
+
raise ConfigurationError(
|
|
50
|
+
f"unknown packaged profile {unknown[0]!r}; available profiles: {available}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
raise ConfigurationError(
|
|
57
|
+
f"cannot create profile directory {destination}: {error}"
|
|
58
|
+
) from error
|
|
59
|
+
if not destination.is_dir():
|
|
60
|
+
raise ConfigurationError(
|
|
61
|
+
f"profile destination is not a directory: {destination}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
targets = tuple(destination / name for name in selected)
|
|
65
|
+
collisions = [path for path in targets if path.exists() or path.is_symlink()]
|
|
66
|
+
if collisions:
|
|
67
|
+
raise ConfigurationError(f"profile destination already exists: {collisions[0]}")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with tempfile.TemporaryDirectory(
|
|
71
|
+
prefix=".oar-init-", dir=destination
|
|
72
|
+
) as staging:
|
|
73
|
+
staging_root = Path(staging)
|
|
74
|
+
with as_file(files("openshell_agent_runner.profiles")) as source_root:
|
|
75
|
+
staged_profiles = []
|
|
76
|
+
for name in selected:
|
|
77
|
+
staged = staging_root / name
|
|
78
|
+
shutil.copytree(source_root / name, staged)
|
|
79
|
+
_configure_runtime(staged, model_id, thinking)
|
|
80
|
+
load_profile(staged)
|
|
81
|
+
staged_profiles.append(staged)
|
|
82
|
+
for staged, target in zip(staged_profiles, targets, strict=True):
|
|
83
|
+
staged.rename(target)
|
|
84
|
+
except OSError as error:
|
|
85
|
+
raise ConfigurationError(f"cannot initialize profiles: {error}") from error
|
|
86
|
+
|
|
87
|
+
return targets
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _configure_runtime(
|
|
91
|
+
profile_directory: Path, model_id: str, thinking: ThinkingLevel
|
|
92
|
+
) -> None:
|
|
93
|
+
models_path = profile_directory / "models.json"
|
|
94
|
+
models = json.loads(models_path.read_text(encoding="utf-8"))
|
|
95
|
+
model = models["providers"]["openshell"]["models"][0]
|
|
96
|
+
model["id"] = model_id
|
|
97
|
+
model["reasoning"] = thinking is not ThinkingLevel.OFF
|
|
98
|
+
models_path.write_text(json.dumps(models, indent=2) + "\n", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
settings_path = profile_directory / "settings.json"
|
|
101
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
102
|
+
settings["defaultModel"] = model_id
|
|
103
|
+
settings["defaultThinkingLevel"] = thinking.value
|
|
104
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"providers": {
|
|
3
|
+
"openshell": {
|
|
4
|
+
"baseUrl": "https://inference.local/v1",
|
|
5
|
+
"api": "openai-completions",
|
|
6
|
+
"apiKey": "unused",
|
|
7
|
+
"authHeader": true,
|
|
8
|
+
"compat": {
|
|
9
|
+
"supportsDeveloperRole": false
|
|
10
|
+
},
|
|
11
|
+
"models": [
|
|
12
|
+
{
|
|
13
|
+
"id": "MODEL_ID",
|
|
14
|
+
"reasoning": true
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
filesystem_policy:
|
|
4
|
+
include_workdir: false
|
|
5
|
+
read_only: [/usr, /lib, /proc, /dev/urandom, /etc, /opt/oar]
|
|
6
|
+
read_write: [/workspace, /sandbox, /tmp, /dev/null]
|
|
7
|
+
|
|
8
|
+
landlock:
|
|
9
|
+
compatibility: hard_requirement
|
|
10
|
+
|
|
11
|
+
process:
|
|
12
|
+
run_as_user: "1000"
|
|
13
|
+
run_as_group: "1000"
|
|
14
|
+
|
|
15
|
+
network_policies: {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
id: reviewer
|
|
2
|
+
description: Review a required input document and publish the result.
|
|
3
|
+
|
|
4
|
+
sandbox:
|
|
5
|
+
policy: policy.yaml
|
|
6
|
+
|
|
7
|
+
tasks:
|
|
8
|
+
review:
|
|
9
|
+
description: Review an input document and return a useful written result.
|
|
10
|
+
required_input: document
|
|
11
|
+
prompt: prompt.md
|
|
12
|
+
tools: [read, grep, find, ls, bash]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Review the input document
|
|
2
|
+
|
|
3
|
+
Act as a coding agent. Inspect `/workspace/input/document.md`, using the declared
|
|
4
|
+
tools as needed. Return a concise Markdown review that identifies the document's
|
|
5
|
+
strengths and the most useful improvements to its clarity and completeness.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Resolve and run one configured task in an OpenShell sandbox."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import secrets
|
|
10
|
+
import shlex
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import openshell_agent_runner.openshell as openshell
|
|
18
|
+
from openshell_agent_runner.artifacts import (
|
|
19
|
+
MAX_ARTIFACT_BYTES,
|
|
20
|
+
atomic_publish,
|
|
21
|
+
validate_artifact,
|
|
22
|
+
)
|
|
23
|
+
from openshell_agent_runner.config import (
|
|
24
|
+
ResolvedProfile,
|
|
25
|
+
resolve_task,
|
|
26
|
+
validate_environment_assignments,
|
|
27
|
+
validate_upload_mappings,
|
|
28
|
+
)
|
|
29
|
+
from openshell_agent_runner.errors import ConfigurationError, ExecutionError
|
|
30
|
+
from openshell_agent_runner.harnesses.pi.resources import (
|
|
31
|
+
image_directory,
|
|
32
|
+
prepare_resources,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RunRequest:
|
|
38
|
+
profile_directory: Path
|
|
39
|
+
task_id: str
|
|
40
|
+
output: Path
|
|
41
|
+
input_document: Path | None = None
|
|
42
|
+
uploads: Sequence[str] = ()
|
|
43
|
+
environments: Sequence[str] = ()
|
|
44
|
+
gateway: str | None = None
|
|
45
|
+
workspace: str = "default"
|
|
46
|
+
timeout_seconds: int = 1200
|
|
47
|
+
keep_sandbox: bool = False
|
|
48
|
+
openshell_bin: str = "openshell"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ResolvedRun:
|
|
53
|
+
request: RunRequest
|
|
54
|
+
profile: ResolvedProfile
|
|
55
|
+
uploads: tuple[str, ...]
|
|
56
|
+
environments: tuple[str, ...]
|
|
57
|
+
create_command: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_run(request: RunRequest) -> ResolvedRun:
|
|
61
|
+
profile = resolve_task(request.profile_directory, request.task_id)
|
|
62
|
+
task = profile.profile.tasks[request.task_id]
|
|
63
|
+
document_upload = _resolve_document_upload(request, task.required_input)
|
|
64
|
+
uploads = _validate_uploads(
|
|
65
|
+
[
|
|
66
|
+
*profile.profile.sandbox.upload,
|
|
67
|
+
*([document_upload] if document_upload else []),
|
|
68
|
+
*request.uploads,
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
environments = _validate_environments(
|
|
72
|
+
[
|
|
73
|
+
*([_DOCUMENT_INPUT_ENVIRONMENT] if document_upload else []),
|
|
74
|
+
*profile.profile.sandbox.env,
|
|
75
|
+
*request.environments,
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
sandbox = profile.profile.sandbox
|
|
79
|
+
command = [request.openshell_bin, "sandbox", "create"]
|
|
80
|
+
if request.gateway:
|
|
81
|
+
command.extend(["--gateway", request.gateway])
|
|
82
|
+
command.extend(
|
|
83
|
+
[
|
|
84
|
+
"--workspace",
|
|
85
|
+
request.workspace,
|
|
86
|
+
"--from",
|
|
87
|
+
str(image_directory()),
|
|
88
|
+
"--policy",
|
|
89
|
+
str(profile.profile_dir / sandbox.policy),
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
for upload in uploads:
|
|
93
|
+
command.extend(["--upload", upload])
|
|
94
|
+
for environment in environments:
|
|
95
|
+
command.extend(["--env", environment])
|
|
96
|
+
command.extend(["--no-auto-providers", "--no-tty", "--approval-mode", "auto"])
|
|
97
|
+
return ResolvedRun(
|
|
98
|
+
request=request,
|
|
99
|
+
profile=profile,
|
|
100
|
+
uploads=uploads,
|
|
101
|
+
environments=environments,
|
|
102
|
+
create_command=tuple(command),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_dry_run(request: RunRequest) -> str:
|
|
107
|
+
"""Render the exact nominal command sequence without executing subprocesses."""
|
|
108
|
+
resolved = resolve_run(request)
|
|
109
|
+
name, token = _identity()
|
|
110
|
+
resources = prepare_resources(resolved.profile, request.task_id)
|
|
111
|
+
try:
|
|
112
|
+
with tempfile.TemporaryDirectory(prefix="oar-output-") as directory:
|
|
113
|
+
downloaded = Path(directory) / "output.download"
|
|
114
|
+
commands = [
|
|
115
|
+
(
|
|
116
|
+
"create",
|
|
117
|
+
openshell.sandbox_create(resolved, resources, name, token),
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
"download",
|
|
121
|
+
openshell.sandbox_download(resolved, name, downloaded),
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
if not request.keep_sandbox:
|
|
125
|
+
commands.extend(
|
|
126
|
+
[
|
|
127
|
+
(
|
|
128
|
+
"verify ownership",
|
|
129
|
+
openshell.sandbox_get(request, name),
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
"delete",
|
|
133
|
+
openshell.sandbox_delete(request, name),
|
|
134
|
+
),
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
lines = [
|
|
138
|
+
"Dry run: no commands were executed.",
|
|
139
|
+
f"Profile: {resolved.profile.profile.id}",
|
|
140
|
+
f"Task: {request.task_id}",
|
|
141
|
+
f"Sandbox: {name}",
|
|
142
|
+
"OpenShell commands:",
|
|
143
|
+
*(f"[{label}] {shlex.join(command)}" for label, command in commands),
|
|
144
|
+
"Host actions:",
|
|
145
|
+
_validation_preview(resolved, downloaded),
|
|
146
|
+
f"[publish] atomically replace {request.output}",
|
|
147
|
+
]
|
|
148
|
+
if request.keep_sandbox:
|
|
149
|
+
lines.append("[cleanup] skipped because --keep-sandbox is set")
|
|
150
|
+
else:
|
|
151
|
+
lines.append(
|
|
152
|
+
"[cleanup] ownership verification and deletion also run after "
|
|
153
|
+
"failures when the sandbox can be inspected"
|
|
154
|
+
)
|
|
155
|
+
return "\n".join(lines) + "\n"
|
|
156
|
+
finally:
|
|
157
|
+
resources.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_agent(request: RunRequest) -> str:
|
|
161
|
+
resolved = resolve_run(request)
|
|
162
|
+
name, token = _identity()
|
|
163
|
+
resources = prepare_resources(resolved.profile, request.task_id)
|
|
164
|
+
create = openshell.sandbox_create(resolved, resources, name, token)
|
|
165
|
+
primary_error: BaseException | None = None
|
|
166
|
+
try:
|
|
167
|
+
openshell.run(create, request.timeout_seconds)
|
|
168
|
+
task = resolved.profile.profile.tasks[request.task_id]
|
|
169
|
+
with tempfile.TemporaryDirectory(prefix="oar-output-") as directory:
|
|
170
|
+
downloaded = Path(directory) / "output.download"
|
|
171
|
+
downloaded.touch(mode=0o600)
|
|
172
|
+
openshell.run(
|
|
173
|
+
openshell.sandbox_download(resolved, name, downloaded),
|
|
174
|
+
120,
|
|
175
|
+
max_file_bytes=MAX_ARTIFACT_BYTES,
|
|
176
|
+
)
|
|
177
|
+
schema_path = (
|
|
178
|
+
resolved.profile.profile_dir / task.output_schema
|
|
179
|
+
if task.output_schema is not None
|
|
180
|
+
else None
|
|
181
|
+
)
|
|
182
|
+
validate_artifact(downloaded, schema_path)
|
|
183
|
+
atomic_publish(downloaded, request.output)
|
|
184
|
+
return name
|
|
185
|
+
except BaseException as error:
|
|
186
|
+
primary_error = error
|
|
187
|
+
raise
|
|
188
|
+
finally:
|
|
189
|
+
resources.close()
|
|
190
|
+
if request.keep_sandbox:
|
|
191
|
+
print(f"oar: sandbox name (--keep-sandbox): {name}", file=sys.stderr)
|
|
192
|
+
else:
|
|
193
|
+
try:
|
|
194
|
+
_verify_ownership(request, name, token)
|
|
195
|
+
openshell.run(openshell.sandbox_delete(request, name), 60)
|
|
196
|
+
except ExecutionError as cleanup_error:
|
|
197
|
+
if primary_error is None:
|
|
198
|
+
raise
|
|
199
|
+
print(
|
|
200
|
+
f"oar: cleanup failed after primary error: {cleanup_error}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_uploads(values: Sequence[str]) -> tuple[str, ...]:
|
|
206
|
+
try:
|
|
207
|
+
return validate_upload_mappings(values)
|
|
208
|
+
except ValueError as error:
|
|
209
|
+
raise ConfigurationError(str(error)) from error
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _validate_environments(values: Sequence[str]) -> tuple[str, ...]:
|
|
213
|
+
try:
|
|
214
|
+
return validate_environment_assignments(values)
|
|
215
|
+
except ValueError as error:
|
|
216
|
+
raise ConfigurationError(str(error)) from error
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _resolve_document_upload(
|
|
220
|
+
request: RunRequest, required_input: str | None
|
|
221
|
+
) -> str | None:
|
|
222
|
+
if required_input is None:
|
|
223
|
+
if request.input_document is not None:
|
|
224
|
+
raise ConfigurationError(
|
|
225
|
+
f"task {request.task_id!r} does not accept --input"
|
|
226
|
+
)
|
|
227
|
+
return None
|
|
228
|
+
if request.input_document is None:
|
|
229
|
+
raise ConfigurationError(f"task {request.task_id!r} requires --input DOCUMENT")
|
|
230
|
+
try:
|
|
231
|
+
document = request.input_document.resolve(strict=True)
|
|
232
|
+
except OSError as error:
|
|
233
|
+
raise ConfigurationError(
|
|
234
|
+
f"input document does not exist: {request.input_document}"
|
|
235
|
+
) from error
|
|
236
|
+
if not document.is_file():
|
|
237
|
+
raise ConfigurationError(f"input document must be a file: {document}")
|
|
238
|
+
return f"{document}:{_DOCUMENT_INPUT_PATH}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _validation_preview(resolved: ResolvedRun, downloaded: Path) -> str:
|
|
242
|
+
task = resolved.profile.profile.tasks[resolved.request.task_id]
|
|
243
|
+
if task.output_schema is None:
|
|
244
|
+
return f"[validate] {downloaded} is present, non-empty, and bounded"
|
|
245
|
+
schema = resolved.profile.profile_dir / task.output_schema
|
|
246
|
+
return f"[validate] {downloaded} as JSON against {schema}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _identity() -> tuple[str, str]:
|
|
250
|
+
token = secrets.token_hex(8)[:15]
|
|
251
|
+
return f"oar-{token}", token
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _verify_ownership(request: RunRequest, name: str, token: str) -> None:
|
|
255
|
+
command = openshell.sandbox_get(request, name)
|
|
256
|
+
result = openshell.run(command, 30, capture=True)
|
|
257
|
+
try:
|
|
258
|
+
document = json.loads(result.stdout)
|
|
259
|
+
except json.JSONDecodeError as error:
|
|
260
|
+
raise ExecutionError(
|
|
261
|
+
f"cleanup ownership response was invalid for {name}"
|
|
262
|
+
) from error
|
|
263
|
+
labels = document.get("labels") if isinstance(document, dict) else None
|
|
264
|
+
owned = (
|
|
265
|
+
isinstance(document, dict)
|
|
266
|
+
and document.get("name") == name
|
|
267
|
+
and isinstance(labels, dict)
|
|
268
|
+
and labels.get(openshell.RESERVED_LABEL) == token
|
|
269
|
+
)
|
|
270
|
+
if not owned:
|
|
271
|
+
raise ExecutionError(
|
|
272
|
+
f"refusing to delete sandbox with mismatched ownership: {name}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_DOCUMENT_INPUT_PATH = "/workspace/input/document.md"
|
|
277
|
+
_DOCUMENT_INPUT_ENVIRONMENT = "REPOSITORY_ROOT=/workspace/input"
|