Merge pull request #3805 from rudycelekli/fix/registered-workspace-preview-20261021

fix(web): serve artifacts from registered agent workspaces
This commit is contained in:
zhayujie authored and GitHub committed 2026-10-25 10:43:49 +08:00
commit 968a68b5f5
3 files changed
+115 -3

No files matched your search

+10 -2
View File
@@ -833,8 +833,8 @@ def _live_channel_manager():
def _serve_allowed_roots() -> list:
"""Roots that /api/file and /preview may read from (symlinks resolved).
Includes the configured serve root, the Agent workspace, and any project
directory a session has opened. Project dirs may live outside the serve
Includes the configured serve root, all configured Agent workspaces
(including disabled Agents), and any project directory a session has opened. Project dirs may live outside the serve
root (e.g. ``/tmp/foo``), so previewing files in an opened project would
otherwise be denied.
"""
@@ -843,6 +843,14 @@ def _serve_allowed_roots() -> list:
os.path.realpath(os.path.expanduser(serve_root)),
os.path.realpath(_get_workspace_root()),
]
# Artifact URLs are global, and the artifact index includes disabled
# Agents too. Their explicitly configured workspaces remain readable.
try:
from agent.registry import get_agent_registry
for profile in get_agent_registry().list(include_disabled=True):
roots.append(os.path.realpath(profile.workspace))
except Exception:
pass
try:
from agent.workspace import project_store
for rec in project_store.list_recents():
+1 -1
View File
@@ -28,7 +28,7 @@ The Web Console is CowAgent's default channel. It runs automatically once starte
| `external_api_enabled` | Turn on the [Chat API](/extensions/chat-api) | `false` |
| `external_api_token` | API key of the [Chat API](/extensions/chat-api), kept while the API is off | `""` |
| `web_session_expire_days` | Login session validity in days | `30` |
| `web_file_serve_root` | Root directory the web console can directly read/send files from. Defaults to the user home dir and agent workspace only; set to `/` to allow the whole filesystem | `"~"` |
| `web_file_serve_root` | Additional root directory the web console can directly read/send files from (defaults to the user home directory). Configured Agent workspaces, including disabled Agents, and opened project directories are also allowed; set to `/` to allow the whole filesystem | `"~"` |
| `enable_thinking` | Whether to enable deep thinking mode | `false` |
Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console.
@@ -0,0 +1,104 @@
"""Global artifact routes can serve files in configured Agent workspaces."""
from urllib.parse import urlencode
import pytest
web = pytest.importorskip("web")
from agent.registry import AgentProfile, AgentRegistry, set_agent_registry # noqa: E402
from channel.web.api import files # noqa: E402
from channel.web.core import _common # noqa: E402
from config import conf # noqa: E402
@pytest.fixture
def registered_files(tmp_path, monkeypatch):
default = tmp_path / "default"
helper = tmp_path / "helper"
disabled = tmp_path / "disabled"
for folder in (default, helper, disabled):
folder.mkdir()
registry = AgentRegistry(
[
AgentProfile("default", "Default", str(default)),
AgentProfile("helper", "Helper", str(helper)),
AgentProfile("disabled", "Disabled", str(disabled), enabled=False),
],
"default",
)
set_agent_registry(registry)
monkeypatch.setitem(conf(), "web_password", "")
monkeypatch.setitem(conf(), "web_file_serve_root", str(default))
monkeypatch.setattr(_common, "_PREVIEW_SECRET", b"local-test-preview-secret")
app = web.application(
("/api/file", "FileServeHandler", "/preview/(.*)", "PreviewHandler"),
vars(files),
)
return app, {"default": default, "helper": helper, "disabled": disabled}
def request_file(app, path, preview):
url = (
_common._build_preview_url(str(path))
if preview
else "/api/file?" + urlencode({"path": str(path)})
)
return app.request(url)
@pytest.mark.parametrize("preview", [False, True])
@pytest.mark.parametrize("owner", ["default", "helper", "disabled"])
def test_registered_workspace_artifact_bytes_are_readable(
registered_files, owner, preview
):
app, folders = registered_files
path = folders[owner] / "report.txt"
path.write_bytes(b"Native artifact bytes\n")
response = request_file(app, path, preview)
assert response.status == "200 OK"
assert response.data == path.read_bytes()
assert response.headers["Content-Type"] == "text/plain"
@pytest.mark.parametrize("preview", [False, True])
@pytest.mark.parametrize("alias", [False, True])
def test_unregistered_paths_and_workspace_symlink_escapes_stay_denied(
registered_files, tmp_path, alias, preview
):
app, folders = registered_files
unrelated = tmp_path / "helper-unregistered"
unrelated.mkdir()
path = unrelated / "private.txt"
path.write_bytes(b"outside configured roots")
if alias:
link = folders["helper"] / "linked.txt"
try:
link.symlink_to(path)
except OSError:
pytest.skip("symlinks unavailable")
path = link
response = request_file(app, path, preview)
assert response.status == "404 Not Found"
assert b"outside configured roots" not in response.data
def test_global_file_route_still_requires_authentication(registered_files, monkeypatch):
app, folders = registered_files
path = folders["helper"] / "report.txt"
path.write_bytes(b"requires login")
monkeypatch.setitem(conf(), "web_password", "test-password")
response = request_file(app, path, False)
assert response.status == "401 Unauthorized"
assert b"requires login" not in response.data
def test_preview_route_rejects_unsigned_directory_tokens(registered_files):
app, folders = registered_files
path = folders["helper"] / "report.txt"
path.write_bytes(b"requires signature")
url = _common._build_preview_url(str(path))
token, name = url.removeprefix("/preview/").split("/", 1)
response = app.request("/preview/" + token.rsplit(".", 1)[0] + ".invalid/" + name)
assert response.status == "404 Not Found"
assert b"requires signature" not in response.data