Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/en/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ ar sandbox exec <SANDBOX_ID> (--code <src> | --file <path>) [options]
| `SANDBOX_ID` | positional | yes | | Target sandbox id. |
| `--code` | string | one of | | Inline code to run. |
| `--file` | path | one of | | Path to a code file. |
| `--language` | string | no | `python` | `python` or `javascript`. |
| `--language` | string | no | `python` when `--context-id` is not set; mutually exclusive with `--context-id` | `python` or `javascript`. Passing both `--context-id` and `--language` is an error. |
| `--context-id` | string | no | | Stateful context id (see [context](#context-sub-group)). |
| `--timeout` | int | no | `30` | Execution timeout (seconds). |

Expand Down Expand Up @@ -369,11 +369,16 @@ ar sandbox process get sb-001 1234
### process kill

```
ar sandbox process kill <SANDBOX_ID> <PID>
ar sandbox process kill <SANDBOX_ID> <PID> [--force-shell]
```

| Flag | Default | Description |
|------|---------|-------------|
| `--force-shell` | false | If the Process API does not know this PID, fall back to `kill -9 <PID>` via the shell. Useful for ending PIDs that appear in `process list` but were not started through the Process API. |

```bash
ar sandbox process kill sb-001 1234
ar sandbox process kill sb-001 1234 --force-shell
```

---
Expand Down
9 changes: 7 additions & 2 deletions docs/zh/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ ar sandbox exec <SANDBOX_ID> (--code <src> | --file <path>) [options]
| `SANDBOX_ID` | 位置参数 | 是 | | 目标沙箱 id。 |
| `--code` | string | 二选一 | | 内联代码。 |
| `--file` | path | 二选一 | | 代码文件路径。 |
| `--language` | string | 否 | `python` | `python` 或 `javascript`。 |
| `--language` | string | 否 | 不带 `--context-id` 时为 `python`;与 `--context-id` 互斥 | `python` 或 `javascript`。与 `--context-id` 同时传会报错。 |
| `--context-id` | string | 否 | | 有状态上下文 id(见 [context](#context-子命令组))。 |
| `--timeout` | int | 否 | `30` | 执行超时(秒)。 |

Expand Down Expand Up @@ -367,11 +367,16 @@ ar sandbox process get sb-001 1234
### process kill

```
ar sandbox process kill <SANDBOX_ID> <PID>
ar sandbox process kill <SANDBOX_ID> <PID> [--force-shell]
```

| Flag | 默认 | 说明 |
|------|------|------|
| `--force-shell` | false | Process API 找不到该 PID 时,回退为在沙箱内执行 `kill -9 <PID>`。适合终止 `process list` 显示但未由 Process API 登记的普通 PID。 |

```bash
ar sandbox process kill sb-001 1234
ar sandbox process kill sb-001 1234 --force-shell
```

---
Expand Down
14 changes: 12 additions & 2 deletions src/agentrun_cli/commands/sandbox/context_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
from ._helpers import _build_cfg


def _serialize_context(ops):
"""Flatten the SDK's ContextOperations chain object into a dict."""
cid = getattr(ops, "context_id", None) or getattr(ops, "_context_id", None)
return {
"id": cid,
"language": getattr(ops, "_language", None),
"cwd": getattr(ops, "_cwd", None),
}


@click.group("context", help="Manage execution contexts.")
def context_group():
pass
Expand All @@ -26,7 +36,7 @@ def context_create(ctx, sandbox_id, language, cwd):
cfg = _build_cfg(ctx)
sb = Sandbox.connect(sandbox_id, config=cfg)
result = sb.context.create(language=language, cwd=cwd)
format_output(ctx, result)
format_output(ctx, _serialize_context(result), quiet_field="id")


@context_group.command("list")
Expand Down Expand Up @@ -55,7 +65,7 @@ def context_get(ctx, sandbox_id, context_id):
cfg = _build_cfg(ctx)
sb = Sandbox.connect(sandbox_id, config=cfg)
result = sb.context.get(context_id=context_id)
format_output(ctx, result)
format_output(ctx, _serialize_context(result), quiet_field="id")


@context_group.command("delete")
Expand Down
8 changes: 7 additions & 1 deletion src/agentrun_cli/commands/sandbox/exec_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def register_exec_commands(sandbox_group: click.Group):
@click.argument("sandbox_id")
@click.option("--code", default=None, help="Inline code to execute.")
@click.option("--file", "code_file", default=None, type=click.Path(exists=True), help="Path to code file.")
@click.option("--language", default="python", help="Language: python / javascript.")
@click.option("--language", default=None, help="Language: python / javascript. Defaults to python when --context-id is not set; must be omitted when --context-id is set.")
@click.option("--context-id", default=None, help="Context ID for stateful execution.")
@click.option("--timeout", type=int, default=30, help="Execution timeout (seconds).")
@click.pass_context
Expand All @@ -24,6 +24,12 @@ def sandbox_exec(ctx, sandbox_id, code, code_file, language, context_id, timeout
"""Execute code in a sandbox."""
from agentrun.sandbox import Sandbox

if context_id and language:
raise click.UsageError("--context-id and --language are mutually exclusive.")

if not context_id and not language:
language = "python"

cfg = _build_cfg(ctx)
code_str = _read_code_input(code, code_file)
sb = Sandbox.connect(sandbox_id, config=cfg)
Expand Down
5 changes: 4 additions & 1 deletion src/agentrun_cli/commands/sandbox/file_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ def file_upload(ctx, sandbox_id, local_path, remote_path):

cfg = _build_cfg(ctx)
sb = Sandbox.connect(sandbox_id, config=cfg)
result = sb.file_system.upload(local_path=local_path, remote_path=remote_path)
result = sb.file_system.upload(
local_file_path=local_path,
target_file_path=remote_path,
)
format_output(ctx, result)


Expand Down
34 changes: 30 additions & 4 deletions src/agentrun_cli/commands/sandbox/process_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ def process_group():
@click.pass_context
@handle_errors
def process_list(ctx, sandbox_id):
"""List processes in the sandbox."""
"""List processes in the sandbox.

Returns all processes visible to the container, including ones not
started via the Process API. To act on a process via ``get`` / ``kill``,
start it with ``cmd`` first, or fall back to ``cmd --command "kill <pid>"``.
"""
from agentrun.sandbox import Sandbox

cfg = _build_cfg(ctx)
Expand All @@ -33,7 +38,7 @@ def process_list(ctx, sandbox_id):
@click.pass_context
@handle_errors
def process_get(ctx, sandbox_id, pid):
"""Get process details."""
"""Get process details. Only PIDs started via the Process API are resolvable."""
from agentrun.sandbox import Sandbox

cfg = _build_cfg(ctx)
Expand All @@ -45,13 +50,34 @@ def process_get(ctx, sandbox_id, pid):
@process_group.command("kill")
@click.argument("sandbox_id")
@click.argument("pid")
@click.option(
"--force-shell",
is_flag=True,
help="If the Process API does not know this PID, fall back to 'kill -9 <pid>' via the shell.",
)
@click.pass_context
@handle_errors
def process_kill(ctx, sandbox_id, pid):
"""Kill a process in the sandbox."""
def process_kill(ctx, sandbox_id, pid, force_shell):
"""Kill a process in the sandbox.

By default this targets processes registered through the Process API.
Container-level PIDs returned by ``process list`` but not registered
through Process API will report ``process with PID ... not found``; pass
``--force-shell`` to fall back to ``kill -9 <pid>`` via the shell.
"""
from agentrun.sandbox import Sandbox

cfg = _build_cfg(ctx)
sb = Sandbox.connect(sandbox_id, config=cfg)

if force_shell:
shell_result = sb.process.cmd(command=f"kill -9 {pid}", cwd="/", timeout=10)
format_output(
ctx,
{"pid": pid, "killed_via": "shell", "result": shell_result},
quiet_field="pid",
)
return

result = sb.process.kill(pid=pid)
format_output(ctx, result if result else {"pid": pid, "killed": True})
7 changes: 5 additions & 2 deletions src/agentrun_cli/commands/sandbox/template_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,13 @@ def template_get(ctx, template_name):
@handle_errors
def template_list(ctx, page, page_size, tpl_type):
"""List sandbox templates."""
from agentrun.sandbox import PageableInput, Sandbox
from agentrun.sandbox import PageableInput, Sandbox, TemplateType

cfg = _build_cfg(ctx)
inp = PageableInput(page_number=page, page_size=page_size)
kwargs = {"page_number": page, "page_size": page_size}
if tpl_type is not None:
kwargs["template_type"] = TemplateType(tpl_type)
inp = PageableInput(**kwargs)
templates = Sandbox.list_templates(inp, config=cfg)
rows = [t.model_dump(by_alias=False) for t in templates]
format_output(ctx, rows)
Expand Down
105 changes: 102 additions & 3 deletions tests/integration/test_sandbox_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,23 @@ def test_template_list(self):
data = json.loads(result.output)
assert len(data) == 2

def test_template_list_with_type_filter(self):
"""--type should be propagated into PageableInput."""
mock_mod = _mock_sandbox_modules()
mock_mod.Sandbox.list_templates.return_value = [_make_template_obj(template_type="Browser")]
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, [
"sandbox", "template", "list",
"--type", "Browser",
"--page-size", "5",
])
assert result.exit_code == 0, result.output

call_kwargs = mock_mod.PageableInput.call_args.kwargs
assert call_kwargs["template_type"] == "Browser"
assert call_kwargs["page_size"] == 5

def test_template_update(self):
mock_mod = _mock_sandbox_modules()
existing = _make_template_obj()
Expand Down Expand Up @@ -266,6 +283,44 @@ def test_exec_with_code(self):
data = json.loads(result.output)
assert data["output"] == "hello\n"

call_kwargs = sb.context.execute.call_args.kwargs
assert call_kwargs["language"] == "python"
assert call_kwargs["context_id"] is None

def test_exec_with_context_id_drops_language(self):
"""--context-id alone should send language=None (server forbids both)."""
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
sb.context.execute.return_value = {"output": "ok\n"}
mock_mod.Sandbox.connect.return_value = sb
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, [
"sandbox", "exec", "sb-xxx",
"--code", "print('x')",
"--context-id", "ctx-1",
])
assert result.exit_code == 0, result.output

call_kwargs = sb.context.execute.call_args.kwargs
assert call_kwargs["language"] is None
assert call_kwargs["context_id"] == "ctx-1"

def test_exec_context_id_and_language_mutually_exclusive(self):
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
mock_mod.Sandbox.connect.return_value = sb
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, [
"sandbox", "exec", "sb-xxx",
"--code", "print('x')",
"--context-id", "ctx-1",
"--language", "python",
])
assert result.exit_code != 0
assert "mutually exclusive" in result.output.lower()

def test_cmd(self):
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
Expand All @@ -286,16 +341,24 @@ def test_cmd(self):
class TestContextCommands:

def test_context_create(self):
"""SDK returns a ContextOperations-like object; CLI must flatten it."""
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
sb.context.create.return_value = {"id": "ctx-xxx", "language": "python"}
ops = SimpleNamespace(
context_id="ctx-xxx",
_language="python",
_cwd="/workspace",
)
sb.context.create.return_value = ops
mock_mod.Sandbox.connect.return_value = sb
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, ["sandbox", "context", "create", "sb-xxx"])
result = runner.invoke(cli, ["sandbox", "context", "create", "sb-xxx", "--cwd", "/workspace"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["id"] == "ctx-xxx"
assert data["language"] == "python"
assert data["cwd"] == "/workspace"

def test_context_list(self):
mock_mod = _mock_sandbox_modules()
Expand All @@ -308,14 +371,23 @@ def test_context_list(self):
assert result.exit_code == 0, result.output

def test_context_get(self):
"""SDK returns a ContextOperations-like object; CLI must flatten it."""
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
sb.context.get.return_value = {"id": "ctx-xxx", "language": "python"}
ops = SimpleNamespace(
context_id="ctx-xxx",
_language="python",
_cwd="/home/user",
)
sb.context.get.return_value = ops
mock_mod.Sandbox.connect.return_value = sb
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, ["sandbox", "ctx", "get", "sb-xxx", "ctx-xxx"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["id"] == "ctx-xxx"
assert data["language"] == "python"

def test_context_delete(self):
mock_mod = _mock_sandbox_modules()
Expand Down Expand Up @@ -355,6 +427,7 @@ def test_file_write(self):
assert result.exit_code == 0, result.output

def test_file_upload(self, tmp_path):
"""CLI must call SDK upload with local_file_path / target_file_path kwargs."""
mock_mod, sb = self._setup()
sb.file_system.upload.return_value = {"success": True}
local_file = tmp_path / "data.csv"
Expand All @@ -364,6 +437,12 @@ def test_file_upload(self, tmp_path):
result = runner.invoke(cli, ["sandbox", "f", "upload", "sb-xxx", str(local_file), "/data.csv"])
assert result.exit_code == 0, result.output

call_kwargs = sb.file_system.upload.call_args.kwargs
assert call_kwargs["local_file_path"] == str(local_file)
assert call_kwargs["target_file_path"] == "/data.csv"
assert "local_path" not in call_kwargs
assert "remote_path" not in call_kwargs

def test_file_download(self):
mock_mod, sb = self._setup()
sb.file_system.download.return_value = {"saved_path": "./out.txt", "size": 10}
Expand Down Expand Up @@ -447,6 +526,26 @@ def test_process_kill(self):
data = json.loads(result.output)
assert data["killed"] is True

def test_process_kill_force_shell(self):
"""--force-shell routes through process.cmd('kill -9 <pid>')."""
mock_mod = _mock_sandbox_modules()
sb = _make_sandbox_obj()
sb.process.cmd.return_value = {"exit_code": 0, "stdout": "", "stderr": ""}
mock_mod.Sandbox.connect.return_value = sb
with _patch_sdk(mock_mod):
runner = CliRunner()
result = runner.invoke(cli, ["sandbox", "ps", "kill", "sb-xxx", "128", "--force-shell"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["pid"] == "128"
assert data["killed_via"] == "shell"

call_kwargs = sb.process.cmd.call_args.kwargs
assert call_kwargs["command"] == "kill -9 128"
assert call_kwargs["cwd"] == "/"
# Regular kill should not have been called.
sb.process.kill.assert_not_called()


class TestBrowserCommands:

Expand Down
Loading