From 00ded0df9f5a144519c96adeaf78a5cc34483b75 Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Wed, 22 Apr 2026 18:41:57 +0800 Subject: [PATCH] fix(process_cmd): Add fallback option for killing unregistered PIDs This change introduces a new `--force-shell` flag to the `process kill` command, allowing users to forcefully terminate processes that aren't tracked by the Process API using shell commands. The documentation has also been updated to reflect these changes and clarify usage instructions. Co-developed-by: Aone Copilot Signed-off-by: Sodawyx --- docs/en/sandbox.md | 9 +- docs/zh/sandbox.md | 9 +- .../commands/sandbox/context_cmd.py | 14 ++- src/agentrun_cli/commands/sandbox/exec_cmd.py | 8 +- src/agentrun_cli/commands/sandbox/file_cmd.py | 5 +- .../commands/sandbox/process_cmd.py | 34 +++++- .../commands/sandbox/template_cmd.py | 7 +- tests/integration/test_sandbox_cmd.py | 105 +++++++++++++++++- 8 files changed, 174 insertions(+), 17 deletions(-) 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: