diff --git a/README.md b/README.md index 88b1706..838eeb4 100644 --- a/README.md +++ b/README.md @@ -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://.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/). diff --git a/assets/README_ZH.md b/assets/README_ZH.md index d2570b6..9367bb7 100644 --- a/assets/README_ZH.md +++ b/assets/README_ZH.md @@ -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://.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/)。 diff --git a/skillclaw/api_server.py b/skillclaw/api_server.py index b04e710..f3fb2a7 100644 --- a/skillclaw/api_server.py +++ b/skillclaw/api_server.py @@ -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)) @@ -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 @@ -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 diff --git a/skillclaw/config.py b/skillclaw/config.py index a577ca6..aa76d6f 100644 --- a/skillclaw/config.py +++ b/skillclaw/config.py @@ -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 = "" diff --git a/skillclaw/config_store.py b/skillclaw/config_store.py index 83ad1b5..7686c23 100644 --- a/skillclaw/config_store.py +++ b/skillclaw/config_store.py @@ -77,6 +77,7 @@ def _resolve_config_file(config_file: Path | None = None) -> Path: }, "sharing": { "enabled": False, + "upload_sessions": True, "backend": "", "endpoint": "", "bucket": "", @@ -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, @@ -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)}", diff --git a/skillclaw/setup_wizard.py b/skillclaw/setup_wizard.py index ba46963..0a15ef5 100644 --- a/skillclaw/setup_wizard.py +++ b/skillclaw/setup_wizard.py @@ -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, diff --git a/tests/test_session_upload_control.py b/tests/test_session_upload_control.py new file mode 100644 index 0000000..dd12d34 --- /dev/null +++ b/tests/test_session_upload_control.py @@ -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