Skip to content

Commit a4c69eb

Browse files
authored
Merge pull request #5 from Serverless-Devs/fix/sandbox-cli-issues
fix(process_cmd): Add fallback option for killing unregistered PIDs
2 parents e46d5bb + 00ded0d commit a4c69eb

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)