Skip to content

Conversation

@Soulter
Copy link
Member

@Soulter Soulter commented Jan 13, 2026

Usage

Config Shipyard

git clone https://github.com/AstrBotDevs/shipyard
cd shipyard/pkgs/bay
# edit compose.yml, ACCESS_TOKEN env
docker compose up -d

Config AstrBot

image

test

Enter ChatUI, says: "visualize sin(x) image"

image
  • stage
  • fix: update tool call logging to include tool call IDs and enhance sandbox ship creation parameters
  • feat: file upload
  • fix
  • update
  • fix: remove 'boxlite' option from booter and handle error in PythonTool execution
  • feat: implement singleton pattern for ShipyardSandboxClient and add FileUploadTool for file uploads
  • feat: sandbox
  • fix
  • beta

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

引入基于 Shipyard 工具链的代理沙盒执行环境,并增强仪表盘中的聊天图片处理和布局。

新功能:

  • 添加可配置的沙盒支持,使用 Shipyard 作为后端,在每个会话中执行 Python、Shell 命令和文件系统操作。
  • 在启用沙盒时,为代理流水线提供文件创建、读取、Shell 执行、Python 执行和文件上传工具。
  • 在仪表盘 UI 中为用户和机器人图片添加聊天内图片预览浮层。

改进:

  • 调整机器人嵌入图片和工具调用卡片的样式,以提高可读性和布局效果。
  • 重构沙盒客户端和启动器抽象,以支持可插拔后端和按会话的生命周期管理。

构建:

  • 添加 shipyard-python-sdk 作为新的运行时依赖。

测试:

  • 添加基于 Boxlite 沙盒启动的简单测试脚本。
Original summary in English

Summary by Sourcery

Introduce an agent sandbox execution environment with Shipyard-based tooling and enhance chat image handling and layout in the dashboard.

New Features:

  • Add configurable sandbox support with Shipyard backend for executing Python, shell commands, and filesystem operations per session.
  • Provide file creation, reading, shell execution, Python execution, and file upload tools wired into the agent pipeline when sandbox is enabled.
  • Add in-chat image preview overlay for user and bot images in the dashboard UI.

Enhancements:

  • Adjust bot embedded image and tool-call card styling to improve readability and layout.
  • Refactor sandbox client and booter abstractions to support pluggable backends and per-session lifecycle management.

Build:

  • Add shipyard-python-sdk as a new runtime dependency.

Tests:

  • Add a simple Boxlite-based sandbox boot test harness script.

@auto-assign auto-assign bot requested review from Fridemn and Raven95676 January 13, 2026 11:35
@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Jan 13, 2026
@dosubot dosubot bot added the area:core The bug / feature is about astrbot's core, backend label Jan 13, 2026
@Soulter Soulter changed the title feat/astrbot agent mode feat: astrbot agent sandbox env(improved code interpreter) Jan 13, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 5 个问题,并留下了一些总体反馈:

  • 在 sandbox 工具的 JSON Schema 中有几个很可能是拼写错误或字段不匹配的地方(比如 CreateFileTool.parameters 里 "path": { "path": "string" ... } 写成了 "path" 而不是 "type": "string",以及 PythonTool 里定义的是 "slient",但运行时函数签名使用的是 silent),这会导致校验失败或让工具契约变得令人困惑——请统一这些字段名和类型。
  • SandboxBooter.boot 在基类中是无参数定义,但具体实现却期望接收 session_id,而 SandboxClient.get_booter 目前传入的是基于 session_id 派生的 uuid_str,而不是原始的 session id;建议统一 boot 的签名,并在整个调用链中一致地使用同一个标识符,方便实现方明确依赖它。
  • FileUploadTool 当前接受任意本地绝对路径并直接用它来上传文件;你可能需要对允许的路径做约束/规范化(例如限制在某个配置好的工作空间内),或者增加显式的保护措施/日志,避免无意间访问宿主文件系统。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- 在 sandbox 工具的 JSON Schema 中有几个很可能是拼写错误或字段不匹配的地方(比如 CreateFileTool.parameters 里 `"path": { "path": "string" ... }` 写成了 `"path"` 而不是 `"type": "string"`,以及 PythonTool 里定义的是 `"slient"`,但运行时函数签名使用的是 `silent`),这会导致校验失败或让工具契约变得令人困惑——请统一这些字段名和类型。
- SandboxBooter.boot 在基类中是无参数定义,但具体实现却期望接收 session_id,而 SandboxClient.get_booter 目前传入的是基于 session_id 派生的 uuid_str,而不是原始的 session id;建议统一 boot 的签名,并在整个调用链中一致地使用同一个标识符,方便实现方明确依赖它。
- FileUploadTool 当前接受任意本地绝对路径并直接用它来上传文件;你可能需要对允许的路径做约束/规范化(例如限制在某个配置好的工作空间内),或者增加显式的保护措施/日志,避免无意间访问宿主文件系统。

## Individual Comments

### Comment 1
<location> `astrbot/core/sandbox/tools/fs.py:19-20` </location>
<code_context>
+        default_factory=lambda: {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "path": "string",
+                    "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
+                },
</code_context>

<issue_to_address>
**issue (bug_risk):** The JSON schema for `path` uses `"path"` instead of `"type"`, which will break validation/clients relying on the schema.

In `CreateFileTool.parameters`, the `path` property uses `"path": "string"` instead of `"type": "string"`, making the JSON Schema invalid and inconsistent with your other tool schemas. Please change the key to `"type"` so validators and clients can correctly interpret this field.
</issue_to_address>

### Comment 2
<location> `astrbot/core/sandbox/tools/python.py:23-25` </location>
<code_context>
+                    "type": "string",
+                    "description": "The Python code to execute.",
+                },
+                "slient": {
+                    "type": "boolean",
+                    "description": "Whether to suppress the output of the code execution.",
+                    "default": False,
+                },
</code_context>

<issue_to_address>
**issue (bug_risk):** The `silent` parameter is misspelled as `slient` in the schema and doesn’t match the function signature.

Because the schema exposes `slient` but `run` expects `silent`, any tool using the schema will pass `slient`, which is ignored and leaves `silent` at its default `False`. This makes the option effectively unusable. Please rename the schema field to `silent` and ensure its description/default stay consistent with the function signature.
</issue_to_address>

### Comment 3
<location> `astrbot/core/sandbox/booters/boxlite.py:45-54` </location>
<code_context>
+
+    async def upload_file(self, path: str, remote_path: str) -> dict:
+        """Upload a file to the sandbox"""
+        url = f"http://{self.sb_url}/upload"
+
+        try:
+            # Read file content
+            with open(path, "rb") as f:
+                file_content = f.read()
+
+            # Create multipart form data
+            data = aiohttp.FormData()
+            data.add_field(
+                "file",
+                file_content,
+                filename=remote_path.split("/")[-1],
+                content_type="application/octet-stream",
+            )
+            data.add_field("file_path", remote_path)
+
+            timeout = aiohttp.ClientTimeout(total=120)  # 2 minutes for file upload
+
+            async with aiohttp.ClientSession(timeout=timeout) as session:
+                async with session.post(url, data=data) as response:
+                    if response.status == 200:
+                        return {
</code_context>

<issue_to_address>
**issue (bug_risk):** Upload URL double-prefixes `http://`, which will produce an invalid URL when `sb_url` already includes the scheme.

`MockShipyardSandboxClient.__init__` passes `sb_url` as e.g. `"http://127.0.0.1:{port}"`, but `upload_file` builds `url = f"http://{self.sb_url}/upload"`, yielding `http://http://127.0.0.1:...`. Align this with `_exec_operation`/`wait_healthy` by either storing `sb_url` as `host:port` only or constructing `url = f"{self.sb_url}/upload"` here.
</issue_to_address>

### Comment 4
<location> `astrbot/core/sandbox/booters/base.py:11-16` </location>
<code_context>
+    @property
+    def shell(self) -> ShellComponent: ...
+
+    async def boot(self) -> None: ...
+
+    async def shutdown(self) -> None: ...
+
+    async def upload_file(self, path: str, file_name: str) -> dict:
</code_context>

<issue_to_address>
**suggestion:** The `SandboxBooter.boot` protocol signature doesn’t match the concrete implementations, which require a session identifier.

The protocol declares `async def boot(self) -> None`, but `ShipyardBooter.boot` and `BoxliteBooter.boot` both take a `session_id`, and `SandboxClient.get_booter` calls `await client.boot(uuid_str)`. This mismatch undermines the protocol and static typing. Please update the protocol to `async def boot(self, session_id: str) -> None` (and document the parameter) so the interface matches its implementations.

```suggestion
    @property
    def shell(self) -> ShellComponent: ...

    async def boot(self, session_id: str) -> None:
        """Boot the sandbox for the given session.

        Args:
            session_id: Unique identifier for the sandbox session to boot.
        """
        ...

    async def shutdown(self) -> None: ...
```
</issue_to_address>

### Comment 5
<location> `astrbot/core/sandbox/sandbox_client.py:8` </location>
<code_context>
+
+from .booters.base import SandboxBooter
+
+session_booter: dict[str, SandboxBooter] = {}
+
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider encapsulating the booter registry inside SandboxClient and passing session_id directly to booters to simplify lifecycle management and identifier handling.

You can simplify this without changing behavior by tightening the lifecycle management and removing the redundant UUID handling.

### 1. Avoid module‑level global registry

Right now `session_booter` is a process‑global dict, and `SandboxClient` is just a façade over it. You can encapsulate the registry inside the class to make ownership and lifecycle clearer, while preserving the existing behavior:

```python
# Remove this:
# session_booter: dict[str, SandboxBooter] = {}

class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            # existing construction logic (see below)
            ...
            cls._booters[session_id] = client
        return cls._booters[session_id]
```

This keeps the API identical (`SandboxClient.get_booter(...)`), but makes the registry an explicit part of the abstraction instead of a hidden module global.

If you later want finer lifecycle control (e.g. `shutdown_all()`), it’s straightforward to add classmethods that operate on `cls._booters`.

### 2. Remove redundant UUID computation

Given `ShipyardBooter.boot` already derives a UUID from `session_id`, computing a UUID before calling `boot()` adds confusion and can change identifiers unexpectedly (UUID of UUID).

You can clarify the contract by passing the logical `session_id` through and letting the booter layer own UUID derivation:

```python
class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            if booter_type == "shipyard":
                from .booters.shipyard import ShipyardBooter
                client = ShipyardBooter()
            elif booter_type == "boxlite":
                from .booters.boxlite import BoxliteBooter
                client = BoxliteBooter()
            else:
                raise ValueError(f"Unknown booter type: {booter_type}")

            try:
                # Pass the original session_id; let the booter handle UUIDs.
                await client.boot(session_id)
            except Exception as e:
                logger.error(
                    f"Error booting sandbox for session {session_id}: {e}"
                )
                raise

            cls._booters[session_id] = client

        return cls._booters[session_id]
```

This removes the double UUID indirection and makes it easier to reason about the mapping “session ID → sandbox.”

### 3. Optional: simplify booter selection

If you want to keep the pluggable `booter_type` but reduce branching noise, a small mapping keeps the API flexible without extra complexity at the call site:

```python
BOOTER_FACTORIES: dict[str, type[SandboxBooter]] = {
    "shipyard": None,  # filled lazily
    "boxlite": None,
}

class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            if booter_type == "shipyard":
                from .booters.shipyard import ShipyardBooter
                BooterCls = ShipyardBooter
            elif booter_type == "boxlite":
                from .booters.boxlite import BoxliteBooter
                BooterCls = BoxliteBooter
            else:
                raise ValueError(f"Unknown booter type: {booter_type}")

            client = BooterCls()
            await client.boot(session_id)
            cls._booters[session_id] = client

        return cls._booters[session_id]
```

This keeps the type selection explicit but removes the need for a UUID layer and a free‑floating global dict.
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得这次评审有帮助,欢迎分享 ✨
请帮我变得更有用!欢迎对每条评论点击 👍 或 👎,我会根据反馈改进后续评审。
Original comment in English

Hey - I've found 5 issues, and left some high level feedback:

  • In the sandbox tools JSON schemas there are a couple of likely typos/mismatches (e.g. CreateFileTool.parameters uses "path": { "path": "string" ... } instead of "type": "string", and PythonTool defines "slient" while the run signature uses silent), which will break validation or make the tool contract confusing—align those names and types.
  • SandboxBooter.boot is defined without parameters in the base class but the concrete implementations expect a session_id and SandboxClient.get_booter currently passes a derived uuid_str instead of the original session id; consider standardizing the boot signature and consistently using the same identifier so implementers can rely on what they receive.
  • FileUploadTool currently accepts an arbitrary local absolute path and directly uses it for upload; you may want to constrain/normalize allowed paths (e.g., within a configured workspace) or add explicit safeguards/logging to avoid unintended access to the host filesystem.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the sandbox tools JSON schemas there are a couple of likely typos/mismatches (e.g. CreateFileTool.parameters uses `"path": { "path": "string" ... }` instead of `"type": "string"`, and PythonTool defines `"slient"` while the run signature uses `silent`), which will break validation or make the tool contract confusing—align those names and types.
- SandboxBooter.boot is defined without parameters in the base class but the concrete implementations expect a session_id and SandboxClient.get_booter currently passes a derived uuid_str instead of the original session id; consider standardizing the boot signature and consistently using the same identifier so implementers can rely on what they receive.
- FileUploadTool currently accepts an arbitrary local absolute path and directly uses it for upload; you may want to constrain/normalize allowed paths (e.g., within a configured workspace) or add explicit safeguards/logging to avoid unintended access to the host filesystem.

## Individual Comments

### Comment 1
<location> `astrbot/core/sandbox/tools/fs.py:19-20` </location>
<code_context>
+        default_factory=lambda: {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "path": "string",
+                    "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
+                },
</code_context>

<issue_to_address>
**issue (bug_risk):** The JSON schema for `path` uses `"path"` instead of `"type"`, which will break validation/clients relying on the schema.

In `CreateFileTool.parameters`, the `path` property uses `"path": "string"` instead of `"type": "string"`, making the JSON Schema invalid and inconsistent with your other tool schemas. Please change the key to `"type"` so validators and clients can correctly interpret this field.
</issue_to_address>

### Comment 2
<location> `astrbot/core/sandbox/tools/python.py:23-25` </location>
<code_context>
+                    "type": "string",
+                    "description": "The Python code to execute.",
+                },
+                "slient": {
+                    "type": "boolean",
+                    "description": "Whether to suppress the output of the code execution.",
+                    "default": False,
+                },
</code_context>

<issue_to_address>
**issue (bug_risk):** The `silent` parameter is misspelled as `slient` in the schema and doesn’t match the function signature.

Because the schema exposes `slient` but `run` expects `silent`, any tool using the schema will pass `slient`, which is ignored and leaves `silent` at its default `False`. This makes the option effectively unusable. Please rename the schema field to `silent` and ensure its description/default stay consistent with the function signature.
</issue_to_address>

### Comment 3
<location> `astrbot/core/sandbox/booters/boxlite.py:45-54` </location>
<code_context>
+
+    async def upload_file(self, path: str, remote_path: str) -> dict:
+        """Upload a file to the sandbox"""
+        url = f"http://{self.sb_url}/upload"
+
+        try:
+            # Read file content
+            with open(path, "rb") as f:
+                file_content = f.read()
+
+            # Create multipart form data
+            data = aiohttp.FormData()
+            data.add_field(
+                "file",
+                file_content,
+                filename=remote_path.split("/")[-1],
+                content_type="application/octet-stream",
+            )
+            data.add_field("file_path", remote_path)
+
+            timeout = aiohttp.ClientTimeout(total=120)  # 2 minutes for file upload
+
+            async with aiohttp.ClientSession(timeout=timeout) as session:
+                async with session.post(url, data=data) as response:
+                    if response.status == 200:
+                        return {
</code_context>

<issue_to_address>
**issue (bug_risk):** Upload URL double-prefixes `http://`, which will produce an invalid URL when `sb_url` already includes the scheme.

`MockShipyardSandboxClient.__init__` passes `sb_url` as e.g. `"http://127.0.0.1:{port}"`, but `upload_file` builds `url = f"http://{self.sb_url}/upload"`, yielding `http://http://127.0.0.1:...`. Align this with `_exec_operation`/`wait_healthy` by either storing `sb_url` as `host:port` only or constructing `url = f"{self.sb_url}/upload"` here.
</issue_to_address>

### Comment 4
<location> `astrbot/core/sandbox/booters/base.py:11-16` </location>
<code_context>
+    @property
+    def shell(self) -> ShellComponent: ...
+
+    async def boot(self) -> None: ...
+
+    async def shutdown(self) -> None: ...
+
+    async def upload_file(self, path: str, file_name: str) -> dict:
</code_context>

<issue_to_address>
**suggestion:** The `SandboxBooter.boot` protocol signature doesn’t match the concrete implementations, which require a session identifier.

The protocol declares `async def boot(self) -> None`, but `ShipyardBooter.boot` and `BoxliteBooter.boot` both take a `session_id`, and `SandboxClient.get_booter` calls `await client.boot(uuid_str)`. This mismatch undermines the protocol and static typing. Please update the protocol to `async def boot(self, session_id: str) -> None` (and document the parameter) so the interface matches its implementations.

```suggestion
    @property
    def shell(self) -> ShellComponent: ...

    async def boot(self, session_id: str) -> None:
        """Boot the sandbox for the given session.

        Args:
            session_id: Unique identifier for the sandbox session to boot.
        """
        ...

    async def shutdown(self) -> None: ...
```
</issue_to_address>

### Comment 5
<location> `astrbot/core/sandbox/sandbox_client.py:8` </location>
<code_context>
+
+from .booters.base import SandboxBooter
+
+session_booter: dict[str, SandboxBooter] = {}
+
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider encapsulating the booter registry inside SandboxClient and passing session_id directly to booters to simplify lifecycle management and identifier handling.

You can simplify this without changing behavior by tightening the lifecycle management and removing the redundant UUID handling.

### 1. Avoid module‑level global registry

Right now `session_booter` is a process‑global dict, and `SandboxClient` is just a façade over it. You can encapsulate the registry inside the class to make ownership and lifecycle clearer, while preserving the existing behavior:

```python
# Remove this:
# session_booter: dict[str, SandboxBooter] = {}

class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            # existing construction logic (see below)
            ...
            cls._booters[session_id] = client
        return cls._booters[session_id]
```

This keeps the API identical (`SandboxClient.get_booter(...)`), but makes the registry an explicit part of the abstraction instead of a hidden module global.

If you later want finer lifecycle control (e.g. `shutdown_all()`), it’s straightforward to add classmethods that operate on `cls._booters`.

### 2. Remove redundant UUID computation

Given `ShipyardBooter.boot` already derives a UUID from `session_id`, computing a UUID before calling `boot()` adds confusion and can change identifiers unexpectedly (UUID of UUID).

You can clarify the contract by passing the logical `session_id` through and letting the booter layer own UUID derivation:

```python
class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            if booter_type == "shipyard":
                from .booters.shipyard import ShipyardBooter
                client = ShipyardBooter()
            elif booter_type == "boxlite":
                from .booters.boxlite import BoxliteBooter
                client = BoxliteBooter()
            else:
                raise ValueError(f"Unknown booter type: {booter_type}")

            try:
                # Pass the original session_id; let the booter handle UUIDs.
                await client.boot(session_id)
            except Exception as e:
                logger.error(
                    f"Error booting sandbox for session {session_id}: {e}"
                )
                raise

            cls._booters[session_id] = client

        return cls._booters[session_id]
```

This removes the double UUID indirection and makes it easier to reason about the mapping “session ID → sandbox.”

### 3. Optional: simplify booter selection

If you want to keep the pluggable `booter_type` but reduce branching noise, a small mapping keeps the API flexible without extra complexity at the call site:

```python
BOOTER_FACTORIES: dict[str, type[SandboxBooter]] = {
    "shipyard": None,  # filled lazily
    "boxlite": None,
}

class SandboxClient:
    _booters: dict[str, SandboxBooter] = {}

    @classmethod
    async def get_booter(
        cls,
        session_id: str,
        booter_type: Literal["shipyard", "boxlite"] = "shipyard",
    ) -> SandboxBooter:
        if session_id not in cls._booters:
            if booter_type == "shipyard":
                from .booters.shipyard import ShipyardBooter
                BooterCls = ShipyardBooter
            elif booter_type == "boxlite":
                from .booters.boxlite import BoxliteBooter
                BooterCls = BoxliteBooter
            else:
                raise ValueError(f"Unknown booter type: {booter_type}")

            client = BooterCls()
            await client.boot(session_id)
            cls._booters[session_id] = client

        return cls._booters[session_id]
```

This keeps the type selection explicit but removes the need for a UUID layer and a free‑floating global dict.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +45 to +54
url = f"http://{self.sb_url}/upload"

try:
# Read file content
with open(path, "rb") as f:
file_content = f.read()

# Create multipart form data
data = aiohttp.FormData()
data.add_field(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 上传 URL 进行了双重 http:// 前缀拼接,当 sb_url 已经包含协议时会生成无效的 URL。

MockShipyardSandboxClient.__init__ 传入的 sb_url 示例为 "http://127.0.0.1:{port}",但 upload_file 构造的是 url = f"http://{self.sb_url}/upload",结果变成 http://http://127.0.0.1:...。请将这里的逻辑与 _exec_operation/wait_healthy 对齐,要么将 sb_url 只存为 host:port,要么在这里改为构造 url = f"{self.sb_url}/upload"

Original comment in English

issue (bug_risk): Upload URL double-prefixes http://, which will produce an invalid URL when sb_url already includes the scheme.

MockShipyardSandboxClient.__init__ passes sb_url as e.g. "http://127.0.0.1:{port}", but upload_file builds url = f"http://{self.sb_url}/upload", yielding http://http://127.0.0.1:.... Align this with _exec_operation/wait_healthy by either storing sb_url as host:port only or constructing url = f"{self.sb_url}/upload" here.

Soulter and others added 13 commits January 14, 2026 23:56
* feat: chatui-project

* fix: remove console log from getProjects function
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
@Soulter Soulter merged commit 5eea508 into master Jan 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants