diff --git a/docs/en/sandbox.md b/docs/en/sandbox.md index df4b327..f232360 100644 --- a/docs/en/sandbox.md +++ b/docs/en/sandbox.md @@ -171,7 +171,7 @@ ar sandbox exec (--code | --file ) [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). | @@ -369,11 +369,16 @@ ar sandbox process get sb-001 1234 ### process kill ``` -ar sandbox process kill +ar sandbox process kill [--force-shell] ``` +| Flag | Default | Description | +|------|---------|-------------| +| `--force-shell` | false | If the Process API does not know this PID, fall back to `kill -9 ` 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 ``` --- diff --git a/docs/zh/sandbox.md b/docs/zh/sandbox.md index ef4b6b2..5b6c5fd 100644 --- a/docs/zh/sandbox.md +++ b/docs/zh/sandbox.md @@ -169,7 +169,7 @@ ar sandbox exec (--code | --file ) [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` | 执行超时(秒)。 | @@ -367,11 +367,16 @@ ar sandbox process get sb-001 1234 ### process kill ``` -ar sandbox process kill +ar sandbox process kill [--force-shell] ``` +| Flag | 默认 | 说明 | +|------|------|------| +| `--force-shell` | false | Process API 找不到该 PID 时,回退为在沙箱内执行 `kill -9 `。适合终止 `process list` 显示但未由 Process API 登记的普通 PID。 | + ```bash ar sandbox process kill sb-001 1234 +ar sandbox process kill sb-001 1234 --force-shell ``` --- diff --git a/src/agentrun_cli/commands/sandbox/context_cmd.py b/src/agentrun_cli/commands/sandbox/context_cmd.py index a46e00d..cd564c9 100644 --- a/src/agentrun_cli/commands/sandbox/context_cmd.py +++ b/src/agentrun_cli/commands/sandbox/context_cmd.py @@ -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 @@ -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") @@ -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") diff --git a/src/agentrun_cli/commands/sandbox/exec_cmd.py b/src/agentrun_cli/commands/sandbox/exec_cmd.py index 89657b3..ed1f9a4 100644 --- a/src/agentrun_cli/commands/sandbox/exec_cmd.py +++ b/src/agentrun_cli/commands/sandbox/exec_cmd.py @@ -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 @@ -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) diff --git a/src/agentrun_cli/commands/sandbox/file_cmd.py b/src/agentrun_cli/commands/sandbox/file_cmd.py index f4d2872..b70a8d8 100644 --- a/src/agentrun_cli/commands/sandbox/file_cmd.py +++ b/src/agentrun_cli/commands/sandbox/file_cmd.py @@ -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) diff --git a/src/agentrun_cli/commands/sandbox/process_cmd.py b/src/agentrun_cli/commands/sandbox/process_cmd.py index 9fb96ed..a1c75b4 100644 --- a/src/agentrun_cli/commands/sandbox/process_cmd.py +++ b/src/agentrun_cli/commands/sandbox/process_cmd.py @@ -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 "``. + """ from agentrun.sandbox import Sandbox cfg = _build_cfg(ctx) @@ -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) @@ -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 ' 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 `` 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}) diff --git a/src/agentrun_cli/commands/sandbox/template_cmd.py b/src/agentrun_cli/commands/sandbox/template_cmd.py index 2f0f397..d1769b8 100644 --- a/src/agentrun_cli/commands/sandbox/template_cmd.py +++ b/src/agentrun_cli/commands/sandbox/template_cmd.py @@ -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) diff --git a/tests/integration/test_sandbox_cmd.py b/tests/integration/test_sandbox_cmd.py index ab3801c..61ac98b 100644 --- a/tests/integration/test_sandbox_cmd.py +++ b/tests/integration/test_sandbox_cmd.py @@ -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() @@ -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() @@ -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() @@ -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() @@ -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" @@ -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} @@ -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 ').""" + 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: