agent-sandbox-server 0.3.1a1__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.
- agent_sandbox/server/__init__.py +64 -0
- agent_sandbox/server/__main__.py +224 -0
- agent_sandbox/server/_coerce.py +169 -0
- agent_sandbox/server/_version.py +1 -0
- agent_sandbox/server/agents/__init__.py +66 -0
- agent_sandbox/server/agents/adapters/__init__.py +140 -0
- agent_sandbox/server/agents/adapters/base.py +224 -0
- agent_sandbox/server/agents/adapters/claude_code.py +132 -0
- agent_sandbox/server/agents/adapters/codex.py +303 -0
- agent_sandbox/server/agents/adapters/command.py +126 -0
- agent_sandbox/server/agents/adapters/cursor.py +248 -0
- agent_sandbox/server/agents/adapters/qwen_code.py +135 -0
- agent_sandbox/server/agents/adapters/stream_json.py +269 -0
- agent_sandbox/server/agents/api_common.py +654 -0
- agent_sandbox/server/agents/api_definitions.py +445 -0
- agent_sandbox/server/agents/api_environments.py +568 -0
- agent_sandbox/server/agents/api_other.py +345 -0
- agent_sandbox/server/agents/api_responses.py +1051 -0
- agent_sandbox/server/agents/api_sessions.py +732 -0
- agent_sandbox/server/agents/api_vaults.py +539 -0
- agent_sandbox/server/agents/artifacts.py +201 -0
- agent_sandbox/server/agents/config.py +558 -0
- agent_sandbox/server/agents/errors.py +100 -0
- agent_sandbox/server/agents/events.py +155 -0
- agent_sandbox/server/agents/local_state.py +173 -0
- agent_sandbox/server/agents/procenv.py +221 -0
- agent_sandbox/server/agents/redact.py +94 -0
- agent_sandbox/server/agents/runtime.py +1481 -0
- agent_sandbox/server/agents/store.py +126 -0
- agent_sandbox/server/agents/subagents.py +237 -0
- agent_sandbox/server/agents/webhooks.py +206 -0
- agent_sandbox/server/agents/wire_sessions.py +340 -0
- agent_sandbox/server/agents/workspace_files.py +244 -0
- agent_sandbox/server/app.py +727 -0
- agent_sandbox/server/contract.py +41 -0
- agent_sandbox/server/endpoints.py +892 -0
- agent_sandbox/server/guard.py +230 -0
- agent_sandbox/server/guards/__init__.py +56 -0
- agent_sandbox/server/guards/commands.py +299 -0
- agent_sandbox/server/guards/env.py +64 -0
- agent_sandbox/server/guards/paths.py +99 -0
- agent_sandbox/server/guards/process.py +60 -0
- agent_sandbox/server/guards/urls.py +57 -0
- agent_sandbox/server/policy.py +817 -0
- agent_sandbox/server/py.typed +0 -0
- agent_sandbox/server/registry.py +360 -0
- agent_sandbox/server/router.py +302 -0
- agent_sandbox/server/routes.py +327 -0
- agent_sandbox/server/routes_agents.py +627 -0
- agent_sandbox/server/routes_browser.py +546 -0
- agent_sandbox/server/routes_devtools.py +433 -0
- agent_sandbox/server/routes_files.py +751 -0
- agent_sandbox/server/routes_process.py +483 -0
- agent_sandbox/server/routes_pty.py +772 -0
- agent_sandbox/server/routes_system.py +523 -0
- agent_sandbox/server/runtime.py +793 -0
- agent_sandbox/server/types.py +107 -0
- agent_sandbox_server-0.3.1a1.dist-info/METADATA +86 -0
- agent_sandbox_server-0.3.1a1.dist-info/RECORD +63 -0
- agent_sandbox_server-0.3.1a1.dist-info/WHEEL +4 -0
- agent_sandbox_server-0.3.1a1.dist-info/entry_points.txt +2 -0
- agent_sandbox_server-0.3.1a1.dist-info/licenses/LICENSE +200 -0
- agent_sandbox_server-0.3.1a1.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Container-side HTTP server — stdlib-only, zero external dependencies.
|
|
2
|
+
|
|
3
|
+
Quick start (inside a sandbox template's ``commands.py``)::
|
|
4
|
+
|
|
5
|
+
from agent_sandbox.server import start
|
|
6
|
+
start(9000)
|
|
7
|
+
|
|
8
|
+
Or for more control::
|
|
9
|
+
|
|
10
|
+
from agent_sandbox.server import SandboxServer, ServerPolicy, command
|
|
11
|
+
|
|
12
|
+
@command("greet")
|
|
13
|
+
def greet(name: str) -> str:
|
|
14
|
+
return f"Hello, {name}!"
|
|
15
|
+
|
|
16
|
+
server = SandboxServer(host="0.0.0.0", policy=ServerPolicy.only("transfer"))
|
|
17
|
+
server.serve(port=9000)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from .app import SandboxServer, start
|
|
24
|
+
from .endpoints import CapabilityGroup
|
|
25
|
+
from .policy import PolicyDenied, ServerPolicy
|
|
26
|
+
from .registry import (
|
|
27
|
+
CommandArg,
|
|
28
|
+
CommandRegistry,
|
|
29
|
+
RegisteredCommand,
|
|
30
|
+
command,
|
|
31
|
+
default_registry,
|
|
32
|
+
)
|
|
33
|
+
from .router import RouteInfo, RouteTable, default_table
|
|
34
|
+
from .routes_pty import (
|
|
35
|
+
PtySession,
|
|
36
|
+
PtySessionManager,
|
|
37
|
+
pty_session_manager,
|
|
38
|
+
start_pty_server,
|
|
39
|
+
)
|
|
40
|
+
from .types import ServerRequest, ServerResponse, SSEResponse
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
"SandboxServer",
|
|
45
|
+
"start",
|
|
46
|
+
"ServerPolicy",
|
|
47
|
+
"PolicyDenied",
|
|
48
|
+
"CommandArg",
|
|
49
|
+
"CommandRegistry",
|
|
50
|
+
"RegisteredCommand",
|
|
51
|
+
"command",
|
|
52
|
+
"default_registry",
|
|
53
|
+
"ServerRequest",
|
|
54
|
+
"ServerResponse",
|
|
55
|
+
"SSEResponse",
|
|
56
|
+
"RouteTable",
|
|
57
|
+
"RouteInfo",
|
|
58
|
+
"CapabilityGroup",
|
|
59
|
+
"default_table",
|
|
60
|
+
"PtySession",
|
|
61
|
+
"PtySessionManager",
|
|
62
|
+
"pty_session_manager",
|
|
63
|
+
"start_pty_server",
|
|
64
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""``agent-sandbox-server`` command line (also ``python -m agent_sandbox.server``).
|
|
2
|
+
|
|
3
|
+
Sub-commands:
|
|
4
|
+
|
|
5
|
+
* ``serve`` - run the HTTP (and PTY WebSocket) server.
|
|
6
|
+
* ``healthcheck`` - ``GET /health`` against a running server; exit code 0/1 (Docker
|
|
7
|
+
``HEALTHCHECK`` / platform ``ready_cmd``).
|
|
8
|
+
* ``check-config`` - validate a policy, print the effective endpoint table and lint warnings.
|
|
9
|
+
* ``version`` - print the package version.
|
|
10
|
+
|
|
11
|
+
Stdlib only; ``healthcheck`` imports nothing heavy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib
|
|
19
|
+
import importlib.util
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import contract
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
|
|
30
|
+
__all__ = ["main"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="agent-sandbox-server", description=__doc__.split("\n")[0]
|
|
36
|
+
)
|
|
37
|
+
sub = parser.add_subparsers(dest="command")
|
|
38
|
+
|
|
39
|
+
serve = sub.add_parser("serve", help="run the sandbox server")
|
|
40
|
+
serve.add_argument("--host", default="0.0.0.0") # noqa: S104 (container-internal)
|
|
41
|
+
serve.add_argument("--port", type=int, default=contract.DEFAULT_HTTP_PORT)
|
|
42
|
+
serve.add_argument("--pty-port", type=int, default=None)
|
|
43
|
+
serve.add_argument(
|
|
44
|
+
"--commands",
|
|
45
|
+
action="append",
|
|
46
|
+
default=[],
|
|
47
|
+
metavar="FILE_OR_MODULE",
|
|
48
|
+
help="python file or module that registers commands/routes (repeatable)",
|
|
49
|
+
)
|
|
50
|
+
serve.add_argument("--policy", metavar="FILE", help="policy JSON file")
|
|
51
|
+
serve.add_argument("--preset", help="policy preset (default, transfer-only, ...)")
|
|
52
|
+
serve.add_argument("--base-dir", help="override ABX_SERVER_BASE_DIR")
|
|
53
|
+
|
|
54
|
+
health = sub.add_parser("healthcheck", help="GET /health, exit 0 when healthy")
|
|
55
|
+
health.add_argument("--host", default="127.0.0.1")
|
|
56
|
+
health.add_argument("--port", type=int, default=contract.DEFAULT_HTTP_PORT)
|
|
57
|
+
health.add_argument("--timeout", type=float, default=2.0)
|
|
58
|
+
|
|
59
|
+
check = sub.add_parser("check-config", help="validate and lint a policy")
|
|
60
|
+
check.add_argument("--policy", metavar="FILE")
|
|
61
|
+
check.add_argument("--preset")
|
|
62
|
+
check.add_argument("--json", action="store_true", help="machine-readable output")
|
|
63
|
+
check.add_argument("--strict", action="store_true", help="exit 2 when lint warnings exist")
|
|
64
|
+
check.add_argument("--explain", metavar="ENDPOINT", help="explain a decision for ENDPOINT")
|
|
65
|
+
check.add_argument("--path", help="path for --explain")
|
|
66
|
+
check.add_argument("--scopes", help="comma-separated scopes for --explain (default '*')")
|
|
67
|
+
check.add_argument("--op", choices=["read", "write", "delete", "exec"])
|
|
68
|
+
|
|
69
|
+
sub.add_parser("version", help="print the version")
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_commands(spec: str) -> None:
|
|
74
|
+
"""Import a file or module so its registrations run."""
|
|
75
|
+
if spec.endswith(".py") or os.path.sep in spec or os.path.isfile(spec):
|
|
76
|
+
path = os.path.realpath(spec)
|
|
77
|
+
module_name = f"_abx_user_commands_{hashlib.sha256(os.fsencode(path)).hexdigest()}"
|
|
78
|
+
if module_name in sys.modules:
|
|
79
|
+
return
|
|
80
|
+
module_spec = importlib.util.spec_from_file_location(module_name, path)
|
|
81
|
+
if module_spec is None or module_spec.loader is None:
|
|
82
|
+
raise SystemExit(f"cannot load commands file {spec!r}")
|
|
83
|
+
module = importlib.util.module_from_spec(module_spec)
|
|
84
|
+
previous_path = sys.path[:]
|
|
85
|
+
directory = os.path.dirname(path)
|
|
86
|
+
if directory not in sys.path:
|
|
87
|
+
sys.path.insert(0, directory)
|
|
88
|
+
# Decorators (including dataclasses with postponed annotations) resolve
|
|
89
|
+
# their defining module during execution, as with a normal import.
|
|
90
|
+
sys.modules[module_name] = module
|
|
91
|
+
try:
|
|
92
|
+
module_spec.loader.exec_module(module)
|
|
93
|
+
except BaseException:
|
|
94
|
+
sys.modules.pop(module_name, None)
|
|
95
|
+
sys.path[:] = previous_path
|
|
96
|
+
raise
|
|
97
|
+
else:
|
|
98
|
+
importlib.import_module(spec)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_policy(args: argparse.Namespace) -> Any:
|
|
102
|
+
from .policy import PolicyConfigError, ServerPolicy
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if args.policy:
|
|
106
|
+
policy = ServerPolicy.from_file(args.policy)
|
|
107
|
+
if args.preset:
|
|
108
|
+
policy = policy.with_options(preset=args.preset)
|
|
109
|
+
return policy
|
|
110
|
+
if args.preset:
|
|
111
|
+
return ServerPolicy.preset(args.preset)
|
|
112
|
+
return ServerPolicy.from_environment()
|
|
113
|
+
except PolicyConfigError as exc:
|
|
114
|
+
print(f"policy error: {exc}", file=sys.stderr)
|
|
115
|
+
raise SystemExit(1) from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
119
|
+
from .app import SandboxServer
|
|
120
|
+
|
|
121
|
+
if args.base_dir:
|
|
122
|
+
os.environ["ABX_SERVER_BASE_DIR"] = args.base_dir
|
|
123
|
+
for spec in args.commands:
|
|
124
|
+
_load_commands(spec)
|
|
125
|
+
policy = _resolve_policy(args)
|
|
126
|
+
try:
|
|
127
|
+
server = SandboxServer(host=args.host, pty_port=args.pty_port, policy=policy)
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
print(f"configuration error: {exc}", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
server.serve(args.port)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_healthcheck(args: argparse.Namespace) -> int:
|
|
136
|
+
url = f"http://{args.host}:{args.port}/health"
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(url, timeout=args.timeout) as resp: # noqa: S310
|
|
139
|
+
return 0 if resp.status == 200 else 1
|
|
140
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cmd_check_config(args: argparse.Namespace) -> int:
|
|
145
|
+
from .policy import PolicyConfigError, ServerPolicy
|
|
146
|
+
|
|
147
|
+
policy = _resolve_policy(args) or ServerPolicy.from_dict({})
|
|
148
|
+
try:
|
|
149
|
+
engine = policy.bind()
|
|
150
|
+
except PolicyConfigError as exc:
|
|
151
|
+
print(f"policy error: {exc}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
warnings = engine.lint()
|
|
154
|
+
if args.policy:
|
|
155
|
+
warnings.extend(_file_permission_warnings(args.policy))
|
|
156
|
+
if args.explain:
|
|
157
|
+
scopes = args.scopes.split(",") if args.scopes else None
|
|
158
|
+
decision = engine.explain(args.explain, scopes=scopes, path=args.path, op=args.op)
|
|
159
|
+
print(json.dumps(decision, indent=2))
|
|
160
|
+
return 0 if decision["allowed"] else 3
|
|
161
|
+
rows = []
|
|
162
|
+
for name in sorted(engine._rules):
|
|
163
|
+
rule = engine.rule_for(name)
|
|
164
|
+
route = engine._routes[name]
|
|
165
|
+
rows.append(
|
|
166
|
+
{
|
|
167
|
+
"endpoint": name,
|
|
168
|
+
"group": route.group.value,
|
|
169
|
+
"enabled": rule.enabled,
|
|
170
|
+
"rule": rule.disabled_by or None,
|
|
171
|
+
"scopes": list(rule.scopes),
|
|
172
|
+
"limits": dict(rule.limits),
|
|
173
|
+
"paths": {
|
|
174
|
+
"allow": list(rule.allow),
|
|
175
|
+
"deny": list(rule.deny),
|
|
176
|
+
"read_only": rule.read_only,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
if args.json:
|
|
181
|
+
print(
|
|
182
|
+
json.dumps(
|
|
183
|
+
{"revision": policy.revision, "endpoints": rows, "warnings": warnings}, indent=2
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
print(f"policy {policy!r}")
|
|
188
|
+
for row in rows:
|
|
189
|
+
flag = "on " if row["enabled"] else "off"
|
|
190
|
+
print(f" [{flag}] {row['endpoint']:<24} scopes={','.join(row['scopes']) or '-'}")
|
|
191
|
+
for warning in warnings:
|
|
192
|
+
print(f"warning: {warning}")
|
|
193
|
+
return 2 if warnings and args.strict else 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _file_permission_warnings(path: str) -> list[str]:
|
|
197
|
+
out: list[str] = []
|
|
198
|
+
if os.access(path, os.W_OK):
|
|
199
|
+
out.append(f"L5: policy file {path} is writable by the current user")
|
|
200
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
201
|
+
if os.access(directory, os.W_OK):
|
|
202
|
+
out.append(f"L5: policy directory {directory} is writable by the current user")
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main(argv: list[str] | None = None) -> int:
|
|
207
|
+
"""Entry point; returns the process exit code."""
|
|
208
|
+
parser = _build_parser()
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
command = args.command or "serve"
|
|
211
|
+
if command == "serve" and args.command is None:
|
|
212
|
+
args = parser.parse_args(["serve", *(argv or [])])
|
|
213
|
+
if command == "version":
|
|
214
|
+
print(__version__)
|
|
215
|
+
return 0
|
|
216
|
+
if command == "healthcheck":
|
|
217
|
+
return _cmd_healthcheck(args)
|
|
218
|
+
if command == "check-config":
|
|
219
|
+
return _cmd_check_config(args)
|
|
220
|
+
return _cmd_serve(args)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__": # pragma: no cover
|
|
224
|
+
sys.exit(main())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Shared scalar and string-array type coercion for command arguments.
|
|
2
|
+
|
|
3
|
+
Both the **server** layer (``server/routes.py``) and the client SDK's
|
|
4
|
+
**declarative** layer (``declarative/decorator.py``) need identical
|
|
5
|
+
coerce-and-validate logic. The server-sdk and client-sdk are separate
|
|
6
|
+
distributions, so each ships this module (``agent_sandbox.utils.coerce`` in the
|
|
7
|
+
client); the two files must stay byte-identical, which the client test-suite
|
|
8
|
+
enforces.
|
|
9
|
+
|
|
10
|
+
The functions accept any arg object exposing ``.name``, ``.type``,
|
|
11
|
+
``.required``, and ``.default`` attributes — covering both
|
|
12
|
+
:class:`~agent_sandbox.server.registry.CommandArg` (stdlib dataclass) and
|
|
13
|
+
:class:`~agent_sandbox.models.template.CustomCommandArg` (Pydantic model).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"parse_scalar",
|
|
23
|
+
"coerce_kwargs",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Boolean whitelist
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_BOOL_TRUE: frozenset[str] = frozenset({"true", "yes", "1"})
|
|
31
|
+
_BOOL_FALSE: frozenset[str] = frozenset({"false", "no", "0"})
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Scalar parsing
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_scalar(value: Any, type_str: str) -> Any:
|
|
39
|
+
"""Parse / coerce *value* according to its declared *type_str*.
|
|
40
|
+
|
|
41
|
+
Supported *type_str* values: ``"string"``, ``"integer"``, ``"float"``,
|
|
42
|
+
``"boolean"``, and ``"string_array"`` (JSON arrays of strings only).
|
|
43
|
+
|
|
44
|
+
Bool parsing uses a whitelist (``true/false/yes/no/1/0``) to avoid the
|
|
45
|
+
``bool("False") == True`` pitfall.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
value: The raw value to coerce.
|
|
49
|
+
type_str: One of the supported scalar type strings.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The coerced value.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If *value* cannot be converted or *type_str* is
|
|
56
|
+
unrecognised.
|
|
57
|
+
"""
|
|
58
|
+
if type_str == "string":
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
if type_str == "integer":
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
raise ValueError("JSON boolean is not an integer argument")
|
|
64
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
65
|
+
return value
|
|
66
|
+
return int(value)
|
|
67
|
+
|
|
68
|
+
if type_str == "float":
|
|
69
|
+
if isinstance(value, float):
|
|
70
|
+
return value
|
|
71
|
+
return float(value)
|
|
72
|
+
|
|
73
|
+
if type_str == "boolean":
|
|
74
|
+
if isinstance(value, bool):
|
|
75
|
+
return value
|
|
76
|
+
s = str(value).lower()
|
|
77
|
+
if s in _BOOL_TRUE:
|
|
78
|
+
return True
|
|
79
|
+
if s in _BOOL_FALSE:
|
|
80
|
+
return False
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Cannot parse {value!r} as boolean; accepted values: true/false/yes/no/1/0"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if type_str == "string_array":
|
|
86
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
87
|
+
raise ValueError("Expected a JSON array containing only strings")
|
|
88
|
+
return list(value)
|
|
89
|
+
|
|
90
|
+
raise ValueError(f"Unknown type: {type_str!r}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Arg-like protocol
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@runtime_checkable
|
|
99
|
+
class ArgLike(Protocol):
|
|
100
|
+
"""Minimal interface shared by ``CommandArg`` and ``CustomCommandArg``."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
type: str
|
|
104
|
+
required: bool
|
|
105
|
+
default: str | None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Kwargs coercion / validation
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def coerce_kwargs(
|
|
114
|
+
args: list[Any],
|
|
115
|
+
raw: dict[str, Any],
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""Coerce and validate *raw* kwargs against *args* definitions.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
args: Ordered list of arg descriptors (anything with ``.name``,
|
|
121
|
+
``.type``, ``.required``, ``.default``).
|
|
122
|
+
raw: Raw keyword arguments from the caller / JSON body.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A new dict with values converted to the declared types.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If a required argument is missing or an undeclared
|
|
129
|
+
argument is provided.
|
|
130
|
+
TypeError: If a value cannot be converted to the declared type.
|
|
131
|
+
"""
|
|
132
|
+
declared_names: set[str] = {arg.name for arg in args}
|
|
133
|
+
|
|
134
|
+
# --- Reject undeclared arguments early (fail-fast) ---
|
|
135
|
+
undeclared = sorted(k for k in raw if k not in declared_names)
|
|
136
|
+
if undeclared:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Unexpected argument(s): {undeclared}; declared: {sorted(declared_names) or '(none)'}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# --- Coerce declared arguments ---
|
|
142
|
+
coerced: dict[str, Any] = {}
|
|
143
|
+
for arg in args:
|
|
144
|
+
if arg.name in raw:
|
|
145
|
+
try:
|
|
146
|
+
coerced[arg.name] = parse_scalar(raw[arg.name], arg.type)
|
|
147
|
+
except (ValueError, TypeError) as exc:
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"Cannot convert argument {arg.name!r} to {arg.type}: {exc}"
|
|
150
|
+
) from exc
|
|
151
|
+
elif arg.required:
|
|
152
|
+
raise ValueError(f"Required argument {arg.name!r} missing")
|
|
153
|
+
elif arg.default is not None:
|
|
154
|
+
if arg.type == "string_array":
|
|
155
|
+
try:
|
|
156
|
+
coerced[arg.name] = parse_scalar(json.loads(arg.default), arg.type)
|
|
157
|
+
except (ValueError, TypeError) as exc:
|
|
158
|
+
raise TypeError(
|
|
159
|
+
f"Invalid string_array default for argument {arg.name!r}"
|
|
160
|
+
) from exc
|
|
161
|
+
continue
|
|
162
|
+
# Best-effort coercion of the default; fall back to the raw
|
|
163
|
+
# string if conversion fails (e.g. a placeholder like "N/A").
|
|
164
|
+
try:
|
|
165
|
+
coerced[arg.name] = parse_scalar(arg.default, arg.type)
|
|
166
|
+
except (ValueError, TypeError):
|
|
167
|
+
coerced[arg.name] = arg.default
|
|
168
|
+
|
|
169
|
+
return coerced
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1a1"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Hosted coding-agent runtime for the sandbox HTTP server (opt-in ``agents`` group).
|
|
2
|
+
|
|
3
|
+
Public extension API::
|
|
4
|
+
|
|
5
|
+
from agent_sandbox.server.agents import AgentAdapter, register_adapter
|
|
6
|
+
|
|
7
|
+
See ``docs/en/design/agents-api.md``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from . import events
|
|
13
|
+
from .adapters import (
|
|
14
|
+
AdapterCapabilities,
|
|
15
|
+
AdapterRegistry,
|
|
16
|
+
AgentAdapter,
|
|
17
|
+
EventParser,
|
|
18
|
+
Invocation,
|
|
19
|
+
InvocationFile,
|
|
20
|
+
TurnContext,
|
|
21
|
+
default_adapters,
|
|
22
|
+
register_adapter,
|
|
23
|
+
)
|
|
24
|
+
from .config import (
|
|
25
|
+
AgentSpec,
|
|
26
|
+
GitSpec,
|
|
27
|
+
LimitsSpec,
|
|
28
|
+
McpServerSpec,
|
|
29
|
+
ProviderSpec,
|
|
30
|
+
WorkspaceSpec,
|
|
31
|
+
)
|
|
32
|
+
from .errors import AgentConfigError, AgentNotFoundError, AgentRuntimeError
|
|
33
|
+
from .events import ParsedEvent
|
|
34
|
+
from .redact import Redactor
|
|
35
|
+
from .runtime import AgentRuntime, Item, RuntimeEvent, Session, Turn
|
|
36
|
+
from .store import AgentStore
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AdapterCapabilities",
|
|
40
|
+
"AdapterRegistry",
|
|
41
|
+
"AgentAdapter",
|
|
42
|
+
"AgentConfigError",
|
|
43
|
+
"AgentNotFoundError",
|
|
44
|
+
"AgentRuntime",
|
|
45
|
+
"AgentRuntimeError",
|
|
46
|
+
"AgentSpec",
|
|
47
|
+
"AgentStore",
|
|
48
|
+
"EventParser",
|
|
49
|
+
"GitSpec",
|
|
50
|
+
"Invocation",
|
|
51
|
+
"InvocationFile",
|
|
52
|
+
"Item",
|
|
53
|
+
"LimitsSpec",
|
|
54
|
+
"McpServerSpec",
|
|
55
|
+
"ParsedEvent",
|
|
56
|
+
"ProviderSpec",
|
|
57
|
+
"Redactor",
|
|
58
|
+
"RuntimeEvent",
|
|
59
|
+
"Session",
|
|
60
|
+
"Turn",
|
|
61
|
+
"TurnContext",
|
|
62
|
+
"WorkspaceSpec",
|
|
63
|
+
"default_adapters",
|
|
64
|
+
"events",
|
|
65
|
+
"register_adapter",
|
|
66
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Adapter registry: built-ins, entry points and ``ABX_AGENTS_ADAPTER_MODULES``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from .base import (
|
|
11
|
+
AdapterCapabilities,
|
|
12
|
+
AgentAdapter,
|
|
13
|
+
EventParser,
|
|
14
|
+
Invocation,
|
|
15
|
+
InvocationFile,
|
|
16
|
+
TurnContext,
|
|
17
|
+
)
|
|
18
|
+
from .claude_code import ClaudeCodeAdapter
|
|
19
|
+
from .codex import CodexAdapter
|
|
20
|
+
from .command import CommandAdapter
|
|
21
|
+
from .cursor import CursorAdapter
|
|
22
|
+
from .qwen_code import QwenCodeAdapter
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AdapterCapabilities",
|
|
26
|
+
"AdapterRegistry",
|
|
27
|
+
"AgentAdapter",
|
|
28
|
+
"EventParser",
|
|
29
|
+
"Invocation",
|
|
30
|
+
"InvocationFile",
|
|
31
|
+
"TurnContext",
|
|
32
|
+
"default_adapters",
|
|
33
|
+
"register_adapter",
|
|
34
|
+
"ENTRY_POINT_GROUP",
|
|
35
|
+
"MODULES_ENV_VAR",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ENTRY_POINT_GROUP = "agent_sandbox.agent_adapters"
|
|
39
|
+
MODULES_ENV_VAR = "ABX_AGENTS_ADAPTER_MODULES"
|
|
40
|
+
|
|
41
|
+
_log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AdapterRegistry:
|
|
45
|
+
"""Name/alias → adapter class registry."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, builtins: bool = True) -> None:
|
|
48
|
+
self._classes: dict[str, type[AgentAdapter]] = {}
|
|
49
|
+
self._aliases: dict[str, str] = {}
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
if builtins:
|
|
52
|
+
for cls in (
|
|
53
|
+
CodexAdapter,
|
|
54
|
+
ClaudeCodeAdapter,
|
|
55
|
+
CursorAdapter,
|
|
56
|
+
QwenCodeAdapter,
|
|
57
|
+
CommandAdapter,
|
|
58
|
+
):
|
|
59
|
+
self.register(cls)
|
|
60
|
+
|
|
61
|
+
def register(self, cls: type[AgentAdapter], *, override: bool = False) -> type[AgentAdapter]:
|
|
62
|
+
"""Register *cls*; a name/alias clash is an error unless ``override=True``."""
|
|
63
|
+
name = getattr(cls, "name", None)
|
|
64
|
+
if not name or not isinstance(name, str):
|
|
65
|
+
raise ValueError(f"{cls!r} must define a non-empty class attribute 'name'")
|
|
66
|
+
with self._lock:
|
|
67
|
+
keys = (name, *cls.aliases)
|
|
68
|
+
if not override:
|
|
69
|
+
for key in keys:
|
|
70
|
+
owner = self._aliases.get(key) or (key if key in self._classes else None)
|
|
71
|
+
if owner is not None and owner != name:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"adapter name/alias {key!r} is already registered by {owner!r}"
|
|
74
|
+
)
|
|
75
|
+
if name in self._classes:
|
|
76
|
+
raise ValueError(f"adapter {name!r} is already registered (use override=True)")
|
|
77
|
+
self._classes[name] = cls
|
|
78
|
+
for alias in cls.aliases:
|
|
79
|
+
self._aliases[alias] = name
|
|
80
|
+
return cls
|
|
81
|
+
|
|
82
|
+
def resolve_name(self, name: str) -> str | None:
|
|
83
|
+
"""Canonical adapter name for *name* or an alias (``None`` if unknown)."""
|
|
84
|
+
key = name.strip().lower()
|
|
85
|
+
if key in self._classes:
|
|
86
|
+
return key
|
|
87
|
+
return self._aliases.get(key)
|
|
88
|
+
|
|
89
|
+
def get(self, name: str) -> AgentAdapter | None:
|
|
90
|
+
"""A fresh adapter instance for *name* (or alias)."""
|
|
91
|
+
canonical = self.resolve_name(name)
|
|
92
|
+
return self._classes[canonical]() if canonical else None
|
|
93
|
+
|
|
94
|
+
def names(self) -> list[str]:
|
|
95
|
+
"""Canonical adapter names."""
|
|
96
|
+
return sorted(self._classes)
|
|
97
|
+
|
|
98
|
+
def load_plugins(self) -> list[str]:
|
|
99
|
+
"""Load entry-point and env-var adapters; returns the names loaded."""
|
|
100
|
+
loaded: list[str] = []
|
|
101
|
+
try:
|
|
102
|
+
from importlib.metadata import entry_points
|
|
103
|
+
|
|
104
|
+
for ep in entry_points(group=ENTRY_POINT_GROUP):
|
|
105
|
+
try:
|
|
106
|
+
cls = ep.load()
|
|
107
|
+
self.register(cls)
|
|
108
|
+
loaded.append(cls.name)
|
|
109
|
+
except Exception as exc: # plugin errors must not break the server
|
|
110
|
+
_log.warning("failed to load agent adapter entry point %s: %s", ep.name, exc)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
_log.debug("entry point discovery failed: %s", exc)
|
|
113
|
+
for module in filter(
|
|
114
|
+
None, (m.strip() for m in os.environ.get(MODULES_ENV_VAR, "").split(","))
|
|
115
|
+
):
|
|
116
|
+
try:
|
|
117
|
+
importlib.import_module(module)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
_log.warning("failed to import agent adapter module %s: %s", module, exc)
|
|
120
|
+
return loaded
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_default = AdapterRegistry()
|
|
124
|
+
_plugins_loaded = False
|
|
125
|
+
_plugins_lock = threading.Lock()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def default_adapters() -> AdapterRegistry:
|
|
129
|
+
"""The process-wide registry (plugins are discovered on first use)."""
|
|
130
|
+
global _plugins_loaded
|
|
131
|
+
with _plugins_lock:
|
|
132
|
+
if not _plugins_loaded:
|
|
133
|
+
_plugins_loaded = True
|
|
134
|
+
_default.load_plugins()
|
|
135
|
+
return _default
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register_adapter(cls: type[AgentAdapter], *, override: bool = False) -> type[AgentAdapter]:
|
|
139
|
+
"""Register *cls* in the default registry (usable as a class decorator)."""
|
|
140
|
+
return _default.register(cls, override=override)
|