Skip to content

Commit 00ded0d

Browse files
committed
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 <noreply@alibaba-inc.com> Signed-off-by: Sodawyx <sodawyx@126.com>
1 parent e46d5bb commit 00ded0d

8 files changed

Lines changed: 174 additions & 17 deletions

File tree

docs/en/sandbox.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ ar sandbox exec <SANDBOX_ID> (--code <src> | --file <path>) [options]
171171
| `SANDBOX_ID` | positional | yes | | Target sandbox id. |
172172
| `--code` | string | one of | | Inline code to run. |
173173
| `--file` | path | one of | | Path to a code file. |
174-
| `--language` | string | no | `python` | `python` or `javascript`. |
174+
| `--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. |
175175
| `--context-id` | string | no | | Stateful context id (see [context](#context-sub-group)). |
176176
| `--timeout` | int | no | `30` | Execution timeout (seconds). |
177177

@@ -369,11 +369,16 @@ ar sandbox process get sb-001 1234
369369
### process kill
370370

371371
```
372-
ar sandbox process kill <SANDBOX_ID> <PID>
372+
ar sandbox process kill <SANDBOX_ID> <PID> [--force-shell]
373373
```
374374

375+
| Flag | Default | Description |
376+
|------|---------|-------------|
377+
| `--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. |
378+
375379
```bash
376380
ar sandbox process kill sb-001 1234
381+
ar sandbox process kill sb-001 1234 --force-shell
377382
```
378383

379384
---

docs/zh/sandbox.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ ar sandbox exec <SANDBOX_ID> (--code <src> | --file <path>) [options]
169169
| `SANDBOX_ID` | 位置参数 || | 目标沙箱 id。 |
170170
| `--code` | string | 二选一 | | 内联代码。 |
171171
| `--file` | path | 二选一 | | 代码文件路径。 |
172-
| `--language` | string || `python` | `python``javascript`|
172+
| `--language` | string || 不带 `--context-id` 时为 `python`;与 `--context-id` 互斥 | `python``javascript`。与 `--context-id` 同时传会报错|
173173
| `--context-id` | string || | 有状态上下文 id(见 [context](#context-子命令组))。 |
174174
| `--timeout` | int || `30` | 执行超时(秒)。 |
175175

@@ -367,11 +367,16 @@ ar sandbox process get sb-001 1234
367367
### process kill
368368

369369
```
370-
ar sandbox process kill <SANDBOX_ID> <PID>
370+
ar sandbox process kill <SANDBOX_ID> <PID> [--force-shell]
371371
```
372372

373+
| Flag | 默认 | 说明 |
374+
|------|------|------|
375+
| `--force-shell` | false | Process API 找不到该 PID 时,回退为在沙箱内执行 `kill -9 <PID>`。适合终止 `process list` 显示但未由 Process API 登记的普通 PID。 |
376+
373377
```bash
374378
ar sandbox process kill sb-001 1234
379+
ar sandbox process kill sb-001 1234 --force-shell
375380
```
376381

377382
---

src/agentrun_cli/commands/sandbox/context_cmd.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
from ._helpers import _build_cfg
99

1010

11+
def _serialize_context(ops):
12+
"""Flatten the SDK's ContextOperations chain object into a dict."""
13+
cid = getattr(ops, "context_id", None) or getattr(ops, "_context_id", None)
14+
return {
15+
"id": cid,
16+
"language": getattr(ops, "_language", None),
17+
"cwd": getattr(ops, "_cwd", None),
18+
}
19+
20+
1121
@click.group("context", help="Manage execution contexts.")
1222
def context_group():
1323
pass
@@ -26,7 +36,7 @@ def context_create(ctx, sandbox_id, language, cwd):
2636
cfg = _build_cfg(ctx)
2737
sb = Sandbox.connect(sandbox_id, config=cfg)
2838
result = sb.context.create(language=language, cwd=cwd)
29-
format_output(ctx, result)
39+
format_output(ctx, _serialize_context(result), quiet_field="id")
3040

3141

3242
@context_group.command("list")
@@ -55,7 +65,7 @@ def context_get(ctx, sandbox_id, context_id):
5565
cfg = _build_cfg(ctx)
5666
sb = Sandbox.connect(sandbox_id, config=cfg)
5767
result = sb.context.get(context_id=context_id)
58-
format_output(ctx, result)
68+
format_output(ctx, _serialize_context(result), quiet_field="id")
5969

6070

6171
@context_group.command("delete")

src/agentrun_cli/commands/sandbox/exec_cmd.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def register_exec_commands(sandbox_group: click.Group):
1515
@click.argument("sandbox_id")
1616
@click.option("--code", default=None, help="Inline code to execute.")
1717
@click.option("--file", "code_file", default=None, type=click.Path(exists=True), help="Path to code file.")
18-
@click.option("--language", default="python", help="Language: python / javascript.")
18+
@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.")
1919
@click.option("--context-id", default=None, help="Context ID for stateful execution.")
2020
@click.option("--timeout", type=int, default=30, help="Execution timeout (seconds).")
2121
@click.pass_context
@@ -24,6 +24,12 @@ def sandbox_exec(ctx, sandbox_id, code, code_file, language, context_id, timeout
2424
"""Execute code in a sandbox."""
2525
from agentrun.sandbox import Sandbox
2626

27+
if context_id and language:
28+
raise click.UsageError("--context-id and --language are mutually exclusive.")
29+
30+
if not context_id and not language:
31+
language = "python"
32+
2733
cfg = _build_cfg(ctx)
2834
code_str = _read_code_input(code, code_file)
2935
sb = Sandbox.connect(sandbox_id, config=cfg)

src/agentrun_cli/commands/sandbox/file_cmd.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ def file_upload(ctx, sandbox_id, local_path, remote_path):
6060

6161
cfg = _build_cfg(ctx)
6262
sb = Sandbox.connect(sandbox_id, config=cfg)
63-
result = sb.file_system.upload(local_path=local_path, remote_path=remote_path)
63+
result = sb.file_system.upload(
64+
local_file_path=local_path,
65+
target_file_path=remote_path,
66+
)
6467
format_output(ctx, result)
6568

6669

src/agentrun_cli/commands/sandbox/process_cmd.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ def process_group():
1818
@click.pass_context
1919
@handle_errors
2020
def process_list(ctx, sandbox_id):
21-
"""List processes in the sandbox."""
21+
"""List processes in the sandbox.
22+
23+
Returns all processes visible to the container, including ones not
24+
started via the Process API. To act on a process via ``get`` / ``kill``,
25+
start it with ``cmd`` first, or fall back to ``cmd --command "kill <pid>"``.
26+
"""
2227
from agentrun.sandbox import Sandbox
2328

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

3944
cfg = _build_cfg(ctx)
@@ -45,13 +50,34 @@ def process_get(ctx, sandbox_id, pid):
4550
@process_group.command("kill")
4651
@click.argument("sandbox_id")
4752
@click.argument("pid")
53+
@click.option(
54+
"--force-shell",
55+
is_flag=True,
56+
help="If the Process API does not know this PID, fall back to 'kill -9 <pid>' via the shell.",
57+
)
4858
@click.pass_context
4959
@handle_errors
50-
def process_kill(ctx, sandbox_id, pid):
51-
"""Kill a process in the sandbox."""
60+
def process_kill(ctx, sandbox_id, pid, force_shell):
61+
"""Kill a process in the sandbox.
62+
63+
By default this targets processes registered through the Process API.
64+
Container-level PIDs returned by ``process list`` but not registered
65+
through Process API will report ``process with PID ... not found``; pass
66+
``--force-shell`` to fall back to ``kill -9 <pid>`` via the shell.
67+
"""
5268
from agentrun.sandbox import Sandbox
5369

5470
cfg = _build_cfg(ctx)
5571
sb = Sandbox.connect(sandbox_id, config=cfg)
72+
73+
if force_shell:
74+
shell_result = sb.process.cmd(command=f"kill -9 {pid}", cwd="/", timeout=10)
75+
format_output(
76+
ctx,
77+
{"pid": pid, "killed_via": "shell", "result": shell_result},
78+
quiet_field="pid",
79+
)
80+
return
81+
5682
result = sb.process.kill(pid=pid)
5783
format_output(ctx, result if result else {"pid": pid, "killed": True})

src/agentrun_cli/commands/sandbox/template_cmd.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,13 @@ def template_get(ctx, template_name):
123123
@handle_errors
124124
def template_list(ctx, page, page_size, tpl_type):
125125
"""List sandbox templates."""
126-
from agentrun.sandbox import PageableInput, Sandbox
126+
from agentrun.sandbox import PageableInput, Sandbox, TemplateType
127127

128128
cfg = _build_cfg(ctx)
129-
inp = PageableInput(page_number=page, page_size=page_size)
129+
kwargs = {"page_number": page, "page_size": page_size}
130+
if tpl_type is not None:
131+
kwargs["template_type"] = TemplateType(tpl_type)
132+
inp = PageableInput(**kwargs)
130133
templates = Sandbox.list_templates(inp, config=cfg)
131134
rows = [t.model_dump(by_alias=False) for t in templates]
132135
format_output(ctx, rows)

tests/integration/test_sandbox_cmd.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,23 @@ def test_template_list(self):
134134
data = json.loads(result.output)
135135
assert len(data) == 2
136136

137+
def test_template_list_with_type_filter(self):
138+
"""--type should be propagated into PageableInput."""
139+
mock_mod = _mock_sandbox_modules()
140+
mock_mod.Sandbox.list_templates.return_value = [_make_template_obj(template_type="Browser")]
141+
with _patch_sdk(mock_mod):
142+
runner = CliRunner()
143+
result = runner.invoke(cli, [
144+
"sandbox", "template", "list",
145+
"--type", "Browser",
146+
"--page-size", "5",
147+
])
148+
assert result.exit_code == 0, result.output
149+
150+
call_kwargs = mock_mod.PageableInput.call_args.kwargs
151+
assert call_kwargs["template_type"] == "Browser"
152+
assert call_kwargs["page_size"] == 5
153+
137154
def test_template_update(self):
138155
mock_mod = _mock_sandbox_modules()
139156
existing = _make_template_obj()
@@ -266,6 +283,44 @@ def test_exec_with_code(self):
266283
data = json.loads(result.output)
267284
assert data["output"] == "hello\n"
268285

286+
call_kwargs = sb.context.execute.call_args.kwargs
287+
assert call_kwargs["language"] == "python"
288+
assert call_kwargs["context_id"] is None
289+
290+
def test_exec_with_context_id_drops_language(self):
291+
"""--context-id alone should send language=None (server forbids both)."""
292+
mock_mod = _mock_sandbox_modules()
293+
sb = _make_sandbox_obj()
294+
sb.context.execute.return_value = {"output": "ok\n"}
295+
mock_mod.Sandbox.connect.return_value = sb
296+
with _patch_sdk(mock_mod):
297+
runner = CliRunner()
298+
result = runner.invoke(cli, [
299+
"sandbox", "exec", "sb-xxx",
300+
"--code", "print('x')",
301+
"--context-id", "ctx-1",
302+
])
303+
assert result.exit_code == 0, result.output
304+
305+
call_kwargs = sb.context.execute.call_args.kwargs
306+
assert call_kwargs["language"] is None
307+
assert call_kwargs["context_id"] == "ctx-1"
308+
309+
def test_exec_context_id_and_language_mutually_exclusive(self):
310+
mock_mod = _mock_sandbox_modules()
311+
sb = _make_sandbox_obj()
312+
mock_mod.Sandbox.connect.return_value = sb
313+
with _patch_sdk(mock_mod):
314+
runner = CliRunner()
315+
result = runner.invoke(cli, [
316+
"sandbox", "exec", "sb-xxx",
317+
"--code", "print('x')",
318+
"--context-id", "ctx-1",
319+
"--language", "python",
320+
])
321+
assert result.exit_code != 0
322+
assert "mutually exclusive" in result.output.lower()
323+
269324
def test_cmd(self):
270325
mock_mod = _mock_sandbox_modules()
271326
sb = _make_sandbox_obj()
@@ -286,16 +341,24 @@ def test_cmd(self):
286341
class TestContextCommands:
287342

288343
def test_context_create(self):
344+
"""SDK returns a ContextOperations-like object; CLI must flatten it."""
289345
mock_mod = _mock_sandbox_modules()
290346
sb = _make_sandbox_obj()
291-
sb.context.create.return_value = {"id": "ctx-xxx", "language": "python"}
347+
ops = SimpleNamespace(
348+
context_id="ctx-xxx",
349+
_language="python",
350+
_cwd="/workspace",
351+
)
352+
sb.context.create.return_value = ops
292353
mock_mod.Sandbox.connect.return_value = sb
293354
with _patch_sdk(mock_mod):
294355
runner = CliRunner()
295-
result = runner.invoke(cli, ["sandbox", "context", "create", "sb-xxx"])
356+
result = runner.invoke(cli, ["sandbox", "context", "create", "sb-xxx", "--cwd", "/workspace"])
296357
assert result.exit_code == 0, result.output
297358
data = json.loads(result.output)
298359
assert data["id"] == "ctx-xxx"
360+
assert data["language"] == "python"
361+
assert data["cwd"] == "/workspace"
299362

300363
def test_context_list(self):
301364
mock_mod = _mock_sandbox_modules()
@@ -308,14 +371,23 @@ def test_context_list(self):
308371
assert result.exit_code == 0, result.output
309372

310373
def test_context_get(self):
374+
"""SDK returns a ContextOperations-like object; CLI must flatten it."""
311375
mock_mod = _mock_sandbox_modules()
312376
sb = _make_sandbox_obj()
313-
sb.context.get.return_value = {"id": "ctx-xxx", "language": "python"}
377+
ops = SimpleNamespace(
378+
context_id="ctx-xxx",
379+
_language="python",
380+
_cwd="/home/user",
381+
)
382+
sb.context.get.return_value = ops
314383
mock_mod.Sandbox.connect.return_value = sb
315384
with _patch_sdk(mock_mod):
316385
runner = CliRunner()
317386
result = runner.invoke(cli, ["sandbox", "ctx", "get", "sb-xxx", "ctx-xxx"])
318387
assert result.exit_code == 0, result.output
388+
data = json.loads(result.output)
389+
assert data["id"] == "ctx-xxx"
390+
assert data["language"] == "python"
319391

320392
def test_context_delete(self):
321393
mock_mod = _mock_sandbox_modules()
@@ -355,6 +427,7 @@ def test_file_write(self):
355427
assert result.exit_code == 0, result.output
356428

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

440+
call_kwargs = sb.file_system.upload.call_args.kwargs
441+
assert call_kwargs["local_file_path"] == str(local_file)
442+
assert call_kwargs["target_file_path"] == "/data.csv"
443+
assert "local_path" not in call_kwargs
444+
assert "remote_path" not in call_kwargs
445+
367446
def test_file_download(self):
368447
mock_mod, sb = self._setup()
369448
sb.file_system.download.return_value = {"saved_path": "./out.txt", "size": 10}
@@ -447,6 +526,26 @@ def test_process_kill(self):
447526
data = json.loads(result.output)
448527
assert data["killed"] is True
449528

529+
def test_process_kill_force_shell(self):
530+
"""--force-shell routes through process.cmd('kill -9 <pid>')."""
531+
mock_mod = _mock_sandbox_modules()
532+
sb = _make_sandbox_obj()
533+
sb.process.cmd.return_value = {"exit_code": 0, "stdout": "", "stderr": ""}
534+
mock_mod.Sandbox.connect.return_value = sb
535+
with _patch_sdk(mock_mod):
536+
runner = CliRunner()
537+
result = runner.invoke(cli, ["sandbox", "ps", "kill", "sb-xxx", "128", "--force-shell"])
538+
assert result.exit_code == 0, result.output
539+
data = json.loads(result.output)
540+
assert data["pid"] == "128"
541+
assert data["killed_via"] == "shell"
542+
543+
call_kwargs = sb.process.cmd.call_args.kwargs
544+
assert call_kwargs["command"] == "kill -9 128"
545+
assert call_kwargs["cwd"] == "/"
546+
# Regular kill should not have been called.
547+
sb.process.kill.assert_not_called()
548+
450549

451550
class TestBrowserCommands:
452551

0 commit comments

Comments
 (0)