From 8ed58f61cb508cb3fe97c1f6a2dcc3d74acbec6f Mon Sep 17 00:00:00 2001 From: Upper9527 Date: Wed, 21 Oct 2026 11:26:00 +0800 Subject: [PATCH] fix: reject bucket paths in Cloudflare R2 endpoints --- README.md | 2 ++ assets/README_ZH.md | 2 ++ skillclaw/object_store.py | 9 +++++++ tests/test_object_store_endpoint.py | 41 +++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 tests/test_object_store_endpoint.py diff --git a/README.md b/README.md index aaf4726..88b1706 100644 --- a/README.md +++ b/README.md @@ -517,6 +517,8 @@ To verify the loop: This setup shares session evidence and generated skills with the group. `group_id` is a namespace, not an access-control boundary; use filesystem/share permissions and separate roots for different trust groups. Automated privacy redaction is not provided by this setup. The default publish mode is `direct`; use the `validated` flow above if clients should review candidates before publication. +For Cloudflare R2, use the **account endpoint**, such as `https://.r2.cloudflarestorage.com`, with `sharing.backend s3`, `sharing.bucket `, and `sharing.region auto`. Do not append the bucket to `sharing.endpoint`: boto3 sends the bucket separately. SkillClaw rejects that configuration before making a storage request. See the [R2 S3 API documentation](https://developers.cloudflare.com/r2/api/s3/api/). + ## Quick Reference **Operational model** — Every user has their own `skillclaw` process and `~/.skillclaw/config.yaml`. Each group maps to a `group_id`; one evolve server watches that namespace, turns sessions into skills, and writes them back. Joining a group only requires the correct sharing config on the client side. diff --git a/assets/README_ZH.md b/assets/README_ZH.md index cdcebfd..d2570b6 100644 --- a/assets/README_ZH.md +++ b/assets/README_ZH.md @@ -467,6 +467,8 @@ Bob 使用相同配置,只把 `sharing.local_root` 改为 `/Volumes/skillclaw- 同组用户共享会话证据和生成的技能。`group_id` 只是命名空间,不是权限隔离;不同信任组应使用独立共享根目录和文件系统/共享访问权限。本例没有自动隐私脱敏。默认发布模式为 `direct`;若需先让客户端验证候选技能,请使用上文的 `validated` 流程。 +Cloudflare R2 应使用**账号级 endpoint**,例如 `https://.r2.cloudflarestorage.com`,并配置 `sharing.backend s3`、`sharing.bucket `、`sharing.region auto`。不要把 bucket 拼到 `sharing.endpoint` 后面;boto3 会单独发送 bucket。SkillClaw 会在访问存储前拒绝这种错误配置。参见 [R2 S3 API 文档](https://developers.cloudflare.com/r2/api/s3/api/)。 + ## 快速参考 **运行关系** — 每个用户有自己的 `skillclaw` 进程和 `~/.skillclaw/config.yaml`;每个群组对应一个 `group_id`,由一个 Evolver 持续读取会话、产出技能、写回存储。加入群组只需正确配置共享存储,无需部署服务端。 diff --git a/skillclaw/object_store.py b/skillclaw/object_store.py index deebfef..f972cd9 100644 --- a/skillclaw/object_store.py +++ b/skillclaw/object_store.py @@ -14,6 +14,7 @@ import os from pathlib import Path from typing import Iterator +from urllib.parse import urlsplit class ObjectInfo: @@ -123,6 +124,14 @@ def __init__( region: str = "", session_token: str = "", ) -> None: + parsed_endpoint = urlsplit(endpoint) + if (parsed_endpoint.hostname or "").endswith(".r2.cloudflarestorage.com"): + if parsed_endpoint.path not in {"", "/"}: + raise ValueError( + "Cloudflare R2 endpoint must be the account endpoint without a bucket/path suffix " + "(https://.r2.cloudflarestorage.com). " + "Set the bucket separately in sharing.bucket / EVOLVE_STORAGE_BUCKET." + ) try: import boto3 except ImportError as exc: diff --git a/tests/test_object_store_endpoint.py b/tests/test_object_store_endpoint.py new file mode 100644 index 0000000..527c877 --- /dev/null +++ b/tests/test_object_store_endpoint.py @@ -0,0 +1,41 @@ +from unittest.mock import Mock + +import boto3 +import pytest + +from skillclaw.object_store import S3ObjectStore + + +@pytest.mark.parametrize("path", ["/my-bucket", "/my-bucket/", "/prefix/data"]) +def test_r2_rejects_bucket_suffix_before_opening_session(monkeypatch, path): + session = Mock() + monkeypatch.setattr(boto3.session, "Session", session) + with pytest.raises(ValueError, match="without a bucket/path suffix"): + S3ObjectStore( + endpoint="https://account.r2.cloudflarestorage.com" + path, + bucket="my-bucket", + access_key_id="", + secret_access_key="", + ) + session.assert_not_called() + + +@pytest.mark.parametrize( + "endpoint", + [ + "https://account.r2.cloudflarestorage.com", + "https://account.eu.r2.cloudflarestorage.com/", + "https://s3.example.test/proxy", + "", + ], +) +def test_valid_endpoints_preserve_bucket_and_url(monkeypatch, endpoint): + client = Mock() + session = Mock(return_value=Mock(client=Mock(return_value=client))) + monkeypatch.setattr(boto3.session, "Session", session) + store = S3ObjectStore(endpoint=endpoint, bucket="my-bucket", access_key_id="", secret_access_key="") + expected = {"endpoint_url": endpoint} if endpoint else {} + session.return_value.client.assert_called_once_with("s3", **expected) + client.get_paginator.return_value.paginate.return_value = [{"Contents": [{"Key": "a"}]}] + assert [obj.key for obj in store.iter_objects("prefix/")] == ["a"] + client.get_paginator.return_value.paginate.assert_called_once_with(Bucket="my-bucket", Prefix="prefix/")