Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ To verify the loop:
2. Use the agent through its local proxy. After session finalization (the default idle timeout is 180 seconds), confirm a session artifact reaches `team-lan/sessions/` in the share. The evolver may consume it on the next cycle.
3. Inspect server logs and `evolve_history.jsonl` after a processing cycle. Evolution depends on usable evidence and is not guaranteed to create a new skill every cycle. Accepted changes appear under `team-lan/skills/`; each client's poll then pulls them locally.

To synchronize skills without exporting conversation/tool contents, run `skillclaw config sharing.upload_sessions false` and restart the proxy. This disables both periodic and final session uploads, including uploads to a local shared directory. Skill push/pull remains available. Existing shared artifacts, local records, and requests to your inference provider are unaffected. New setup asks explicitly (default: no); existing configurations keep their previous upload behavior until changed.

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://<ACCOUNT_ID>.r2.cloudflarestorage.com`, with `sharing.backend s3`, `sharing.bucket <bucket-name>`, 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/).
Expand Down
2 changes: 2 additions & 0 deletions assets/README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ Bob 使用相同配置,只把 `sharing.local_root` 改为 `/Volumes/skillclaw-
2. 经本地代理使用 agent。会话结束后(默认空闲超时为 180 秒),确认共享目录的 `team-lan/sessions/` 中出现会话文件;evolver 可能在下一轮处理时将其取走。
3. 查看服务器日志和 `evolve_history.jsonl`。是否产生技能更新取决于证据质量,并非每轮必然更新。接受的变化写入 `team-lan/skills/`,客户端轮询后拉取到本地。

如果只想同步技能、不导出会话和工具内容,运行 `skillclaw config sharing.upload_sessions false` 并重启代理。周期快照和会话结束上传都会关闭(包括本地共享目录),技能推送/拉取仍可用。该开关不删除已共享文件、不关闭本地记录,也不阻止向推理服务发送请求。首次 setup 会明确询问,默认不上传;已有配置保持原来的上传行为,需主动修改。

同组用户共享会话证据和生成的技能。`group_id` 只是命名空间,不是权限隔离;不同信任组应使用独立共享根目录和文件系统/共享访问权限。本例没有自动隐私脱敏。默认发布模式为 `direct`;若需先让客户端验证候选技能,请使用上文的 `validated` 流程。

Cloudflare R2 应使用**账号级 endpoint**,例如 `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,并配置 `sharing.backend s3`、`sharing.bucket <bucket-name>`、`sharing.region auto`。不要把 bucket 拼到 `sharing.endpoint` 后面;boto3 会单独发送 bucket。SkillClaw 会在访问存储前拒绝这种错误配置。参见 [R2 S3 API 文档](https://developers.cloudflare.com/r2/api/s3/api/)。
Expand Down
6 changes: 4 additions & 2 deletions skillclaw/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2117,7 +2117,7 @@ async def _close_session(self, session_id: str, reason: str = "explicit") -> Non
self.skill_manager._save_stats()
turns = self._session_turns.pop(session_id, [])
modified_skill_names = _extract_modified_skill_names(turns)
if turns and self.config.sharing_enabled:
if turns and self.config.sharing_enabled and self.config.sharing_upload_sessions:
self._safe_create_task(self._upload_session_data(session_id, turns))
if self.config.sharing_enabled:
self._safe_create_task(self._pull_skills_from_cloud(skip_names=modified_skill_names))
Expand Down Expand Up @@ -3059,6 +3059,8 @@ async def _upload_session_data(
- sessions: ``{group_id}/sessions/{session_id}.jsonl``
- skills: ``{group_id}/skills/{name}/SKILL.md`` (handled by SkillHub)
"""
if not self.config.sharing_enabled or not self.config.sharing_upload_sessions:
return False
try:
from .skill_hub import SkillHub

Expand Down Expand Up @@ -3103,7 +3105,7 @@ def _advance_user_turn_and_maybe_upload(self, session_id: str) -> int:
def _maybe_upload_session_snapshot(self, session_id: str, user_turn_num: int) -> None:
"""Queue a session snapshot when the user-visible turn cadence is reached."""
interval = max(0, int(getattr(self.config, "sharing_session_upload_interval", 0) or 0))
if not self.config.sharing_enabled or interval <= 0:
if not self.config.sharing_enabled or not self.config.sharing_upload_sessions or interval <= 0:
return
if user_turn_num <= 0 or user_turn_num % interval != 0:
return
Expand Down
1 change: 1 addition & 0 deletions skillclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class SkillClawConfig:
# Skill sharing (generic object storage) #
# ------------------------------------------------------------------ #
sharing_enabled: bool = False
sharing_upload_sessions: bool = True
sharing_backend: str = ""
sharing_endpoint: str = ""
sharing_bucket: str = ""
Expand Down
3 changes: 3 additions & 0 deletions skillclaw/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def _resolve_config_file(config_file: Path | None = None) -> Path:
},
"sharing": {
"enabled": False,
"upload_sessions": True,
"backend": "",
"endpoint": "",
"bucket": "",
Expand Down Expand Up @@ -401,6 +402,7 @@ def to_skillclaw_config(self) -> SkillClawConfig:
configure_openclaw=configure_openclaw,
# Sharing
sharing_enabled=bool(sharing.get("enabled", False)),
sharing_upload_sessions=bool(sharing.get("upload_sessions", True)),
sharing_backend=sharing_backend,
sharing_endpoint=sharing_endpoint,
sharing_bucket=sharing_bucket,
Expand Down Expand Up @@ -536,6 +538,7 @@ def describe(self) -> str:
lines += [
f"sharing.group: {sharing.get('group_id', 'default')}",
f"sharing.alias: {sharing.get('user_alias', '?')}",
f"sharing.upload_sessions: {sharing.get('upload_sessions', True)}",
f"sharing.auto_pull: {sharing.get('auto_pull_on_start', False)}",
"sharing.session_upload_interval: "
f"{_normalize_non_negative_int(sharing.get('session_upload_interval', 0), default=0)}",
Expand Down
5 changes: 5 additions & 0 deletions skillclaw/setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,13 @@ def run(self):
"Auto-pull shared skills on startup",
default=current_sharing.get("auto_pull_on_start", False),
)
upload_sessions = _prompt_bool(
"Upload conversation and tool contents for shared skill evolution",
default=current_sharing.get("upload_sessions", current_sharing.get("enabled", False)),
)
sharing_config = {
"enabled": True,
"upload_sessions": upload_sessions,
"backend": sharing_backend,
"group_id": group_id,
"user_alias": user_alias,
Expand Down
51 changes: 51 additions & 0 deletions tests/test_session_upload_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from unittest.mock import AsyncMock, Mock

import pytest

from skillclaw.api_server import SkillClawAPIServer
from skillclaw.config import SkillClawConfig
from skillclaw.config_store import ConfigStore
from skillclaw.skill_hub import SkillHub


@pytest.mark.anyio
@pytest.mark.parametrize("enabled,upload", [(True, False), (False, True)])
async def test_disabled_upload_never_opens_storage_or_triggers_evolution(monkeypatch, enabled, upload):
server = object.__new__(SkillClawAPIServer)
server.config = SkillClawConfig(sharing_enabled=enabled, sharing_upload_sessions=upload)
storage = Mock(side_effect=AssertionError("must not open session storage"))
monkeypatch.setattr(SkillHub, "object_storage_from_config", storage)
server._trigger_evolve = AsyncMock()
assert await server._upload_session_data("private", [{"prompt_text": "private"}]) is False
await server._upload_session_snapshot_and_trigger("private", [{"prompt_text": "private"}])
storage.assert_not_called()
server._trigger_evolve.assert_not_awaited()


def test_disabled_upload_never_schedules_snapshot():
server = object.__new__(SkillClawAPIServer)
server.config = SkillClawConfig(
sharing_enabled=True, sharing_upload_sessions=False, sharing_session_upload_interval=1
)
server._safe_create_task = Mock()
server._maybe_upload_session_snapshot("private", 1)
server._safe_create_task.assert_not_called()


@pytest.mark.anyio
async def test_skill_pull_still_works_with_session_upload_disabled(monkeypatch, tmp_path):
server = object.__new__(SkillClawAPIServer)
server.config = SkillClawConfig(sharing_enabled=True, sharing_upload_sessions=False, skills_dir=str(tmp_path))
server.skill_manager = None
hub = Mock()
hub.pull_skills.return_value = {"downloaded": 0, "skipped": 0, "failed": 0, "deleted": 0, "total_remote": 0}
monkeypatch.setattr(SkillHub, "from_config", lambda config: hub)
await server._pull_skills_from_cloud()
hub.pull_skills.assert_called_once()


@pytest.mark.parametrize("setting,expected", [("", True), (" upload_sessions: false\n", False)])
def test_config_preserves_legacy_behavior_and_explicit_opt_out(tmp_path, setting, expected):
path = tmp_path / "config.yaml"
path.write_text("sharing:\n enabled: true\n" + setting)
assert ConfigStore(path).to_skillclaw_config().sharing_upload_sessions is expected
Loading