From e7011c905a2f01da195f4ecde94d01ad3d090b84 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Wed, 3 Jun 2026 19:22:06 +0800 Subject: [PATCH 1/2] fix(session): grant full access to ownerless sessions for all local users Channel-originated sessions are created outside an HTTP auth context, leaving owner_user_id and owner_username as None. Previously, any authenticated Web UI user would fail the is_owner() check and be denied read/write/delete access entirely. Introduce _has_no_owner() and a unified is_shared() predicate on SessionPolicy. Sessions with no owner are now treated as locally shared: any authenticated user can read, write, and delete them, and the UI "shared" badge is derived from the same predicate. Existing ownerless sessions are covered with no data migration required. Co-authored-by: Cursor --- flocks/server/routes/session.py | 2 +- flocks/session/policy.py | 35 +++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 23eac9bac..2b6ad7fbd 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -132,7 +132,7 @@ def _session_to_response(session: SessionModel) -> SessionResponse: current_user = get_current_auth_user() can_write = SessionPolicy.can_write(session, current_user) can_delete = SessionPolicy.can_delete(session, current_user) - is_shared = SessionPolicy.is_local_shared(session) + is_shared = SessionPolicy.is_shared(session) return SessionResponse( id=session.id, diff --git a/flocks/session/policy.py b/flocks/session/policy.py index ac088874d..9ef475613 100644 --- a/flocks/session/policy.py +++ b/flocks/session/policy.py @@ -51,6 +51,20 @@ def is_local_shared(session: "SessionInfo") -> bool: return False return bool(metadata.get("shared_local")) + @staticmethod + def _has_no_owner(session: "SessionInfo") -> bool: + return not session.owner_user_id and not session.owner_username + + @classmethod + def is_shared(cls, session: "SessionInfo") -> bool: + """Whether the session is accessible to all local users. + + True when explicitly marked ``shared_local`` or when the session has no + owner (e.g. channel-originated sessions created without an HTTP auth + context). Used both for permission checks and the UI "shared" badge. + """ + return cls.is_local_shared(session) or cls._has_no_owner(session) + @staticmethod def _shared_read_user_ids(session: "SessionInfo") -> set[str]: metadata = getattr(session, "metadata", None) @@ -77,13 +91,16 @@ def can_read(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> Whether the session should be visible in listings / fetch. - No auth context (CLI/internal runtime): keep legacy permissive behaviour. - - Logged-in users: owner or local-shared readers. + - Logged-in users: owner, local-shared readers, or any user for ownerless + sessions (e.g. channel-originated sessions created without an auth context). """ resolved = cls._resolve_user(user) if resolved is None: return True if cls.is_owner(session, resolved): return True + if cls._has_no_owner(session): + return True return cls.is_shared_read_only(session, resolved) @classmethod @@ -91,16 +108,26 @@ def can_write(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> """ Session write permission. - Shared users are read-only. Only owner can write. + Owner can always write. Any authenticated user may write ownerless + sessions (e.g. channel-originated sessions that have no HTTP auth + context at creation time). """ resolved = cls._resolve_user(user) if resolved is None: return False - return cls.is_owner(session, resolved) + if cls.is_owner(session, resolved): + return True + if cls._has_no_owner(session): + return True + return False @classmethod def can_delete(cls, session: "SessionInfo", user: Optional["AuthUser"]) -> bool: resolved = cls._resolve_user(user) if resolved is None: return False - return cls.is_owner(session, resolved) + if cls.is_owner(session, resolved): + return True + if cls._has_no_owner(session): + return True + return False From fe9630f282fb1a97efa3a9f7df708aaf8956f05f Mon Sep 17 00:00:00 2001 From: xiami762 Date: Thu, 4 Jun 2026 10:25:33 +0800 Subject: [PATCH 2/2] fix(channel): assign session ownership and restrict ownerless access Channel-created sessions now resolve a local admin owner or preserve an existing owner on rebind. Ownerless sessions are no longer treated as shared; only admins can manage legacy ownerless sessions. Co-authored-by: Cursor --- flocks/channel/inbound/dispatcher.py | 7 +- flocks/channel/inbound/session_binding.py | 42 ++++++++++++ flocks/session/policy.py | 24 +++---- tests/channel/test_unified_prompt_context.py | 70 ++++++++++++++++++++ tests/session/test_session_policy.py | 19 ++++++ tests/user_workflow/.gitignore | 4 ++ 6 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 tests/user_workflow/.gitignore diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 95ab2d7ff..70647aadd 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -847,19 +847,24 @@ async def _handle_session_command( scope_override: Optional[str], ) -> None: from flocks.session.session import Session - from flocks.channel.inbound.session_binding import _build_title + from flocks.channel.inbound.session_binding import ( + _build_title, + resolve_channel_session_owner_kwargs, + ) session = await Session.get_by_id(binding.session_id) if not session: await callbacks.deliver_text("当前会话不存在,请发送一条普通消息后重试。") return + owner_kwargs = await resolve_channel_session_owner_kwargs(session) new_session = await Session.create( project_id=session.project_id, directory=session.directory, title=_build_title(msg), agent=session.agent, **Session.inherited_model_kwargs(session), + **owner_kwargs, ) new_binding = await self.binding_service.rebind( msg, diff --git a/flocks/channel/inbound/session_binding.py b/flocks/channel/inbound/session_binding.py index 89387d14d..465f21b0d 100644 --- a/flocks/channel/inbound/session_binding.py +++ b/flocks/channel/inbound/session_binding.py @@ -78,6 +78,46 @@ class SessionBinding: # corruption vector. _db_owner_pid: Optional[int] = None + +async def resolve_channel_session_owner_kwargs(source_session=None) -> dict[str, str]: + """Return ownership kwargs for a channel-created session. + + Channel dispatch runs outside the HTTP auth middleware, so + ``Session.create`` cannot infer the owner from ``current_auth_user``. + When an existing channel session is being replaced, preserve its owner. + Otherwise, attach new channel sessions to the local admin if one exists. + Installs without local accounts remain ownerless for backward-compatible + no-login operation. + """ + owner_user_id = getattr(source_session, "owner_user_id", None) if source_session else None + owner_username = getattr(source_session, "owner_username", None) if source_session else None + if owner_user_id or owner_username: + owner_kwargs: dict[str, str] = {} + if owner_user_id: + owner_kwargs["owner_user_id"] = str(owner_user_id) + if owner_username: + owner_kwargs["owner_username"] = str(owner_username) + return owner_kwargs + + try: + from flocks.auth.service import AuthService + + if not await AuthService.has_users(): + return {} + users = await AuthService.list_users() + except Exception as exc: + log.warn("channel.owner.resolve_failed", {"error": str(exc)}) + return {} + + admin = next((user for user in users if getattr(user, "role", None) == "admin"), None) + if admin is None: + return {} + return { + "owner_user_id": str(admin.id), + "owner_username": str(admin.username), + } + + # Register channel_bindings DDL with Storage so the tables are created # during Storage.init() as well (idempotent CREATE IF NOT EXISTS). try: @@ -488,11 +528,13 @@ async def _create_session( from flocks.session.session import Session title = _build_title(msg) + owner_kwargs = await resolve_channel_session_owner_kwargs() session = await Session.create( project_id="channel", directory=_resolve_session_directory(directory), title=title, agent=default_agent, + **owner_kwargs, ) return session.id diff --git a/flocks/session/policy.py b/flocks/session/policy.py index 9ef475613..a1870fb5d 100644 --- a/flocks/session/policy.py +++ b/flocks/session/policy.py @@ -57,13 +57,12 @@ def _has_no_owner(session: "SessionInfo") -> bool: @classmethod def is_shared(cls, session: "SessionInfo") -> bool: - """Whether the session is accessible to all local users. + """Whether the session is explicitly shared to all local users. - True when explicitly marked ``shared_local`` or when the session has no - owner (e.g. channel-originated sessions created without an HTTP auth - context). Used both for permission checks and the UI "shared" badge. + Ownerless sessions are a legacy / unauthenticated compatibility state, + not a sharing state. The UI badge should only reflect explicit sharing. """ - return cls.is_local_shared(session) or cls._has_no_owner(session) + return cls.is_local_shared(session) @staticmethod def _shared_read_user_ids(session: "SessionInfo") -> set[str]: @@ -91,15 +90,15 @@ def can_read(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> Whether the session should be visible in listings / fetch. - No auth context (CLI/internal runtime): keep legacy permissive behaviour. - - Logged-in users: owner, local-shared readers, or any user for ownerless - sessions (e.g. channel-originated sessions created without an auth context). + - Logged-in users: owner, local-shared readers, shared readers, or admins + managing ownerless legacy/channel sessions. """ resolved = cls._resolve_user(user) if resolved is None: return True if cls.is_owner(session, resolved): return True - if cls._has_no_owner(session): + if cls._has_no_owner(session) and cls.is_admin(resolved): return True return cls.is_shared_read_only(session, resolved) @@ -108,16 +107,15 @@ def can_write(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> """ Session write permission. - Owner can always write. Any authenticated user may write ownerless - sessions (e.g. channel-originated sessions that have no HTTP auth - context at creation time). + Owner can always write. Admins may repair/manage ownerless sessions + accumulated before local ownership was available. """ resolved = cls._resolve_user(user) if resolved is None: return False if cls.is_owner(session, resolved): return True - if cls._has_no_owner(session): + if cls._has_no_owner(session) and cls.is_admin(resolved): return True return False @@ -128,6 +126,6 @@ def can_delete(cls, session: "SessionInfo", user: Optional["AuthUser"]) -> bool: return False if cls.is_owner(session, resolved): return True - if cls._has_no_owner(session): + if cls._has_no_owner(session) and cls.is_admin(resolved): return True return False diff --git a/tests/channel/test_unified_prompt_context.py b/tests/channel/test_unified_prompt_context.py index 78eaf57af..ac09461b3 100644 --- a/tests/channel/test_unified_prompt_context.py +++ b/tests/channel/test_unified_prompt_context.py @@ -37,6 +37,7 @@ from flocks.channel.inbound.session_binding import ( SessionBindingService, _resolve_session_directory, + resolve_channel_session_owner_kwargs, ) from flocks.config.config import ChannelConfig @@ -228,6 +229,75 @@ async def _fake_create(**kwargs): assert captured["directory"] == "/instance/dir" +class TestChannelSessionOwnerPropagation: + @pytest.mark.asyncio + async def test_create_session_assigns_local_admin_owner(self): + captured = {} + + class _StubSession: + id = "ses_admin_owned" + + async def _fake_create(**kwargs): + captured.update(kwargs) + return _StubSession() + + admin = SimpleNamespace(id="usr_admin", username="admin", role="admin") + + with patch("flocks.session.session.Session.create", new=_fake_create), \ + patch("flocks.auth.service.AuthService.has_users", new=AsyncMock(return_value=True)), \ + patch("flocks.auth.service.AuthService.list_users", new=AsyncMock(return_value=[admin])): + sid = await SessionBindingService._create_session( + _msg(), + default_agent="rex", + directory="/explicit/dir", + ) + + assert sid == "ses_admin_owned" + assert captured["owner_user_id"] == "usr_admin" + assert captured["owner_username"] == "admin" + + @pytest.mark.asyncio + async def test_create_session_stays_ownerless_without_local_accounts(self): + captured = {} + + class _StubSession: + id = "ses_ownerless" + + async def _fake_create(**kwargs): + captured.update(kwargs) + return _StubSession() + + with patch("flocks.session.session.Session.create", new=_fake_create), \ + patch("flocks.auth.service.AuthService.has_users", new=AsyncMock(return_value=False)), \ + patch("flocks.auth.service.AuthService.list_users", new=AsyncMock()) as list_users: + sid = await SessionBindingService._create_session( + _msg(), + default_agent="rex", + directory="/explicit/dir", + ) + + assert sid == "ses_ownerless" + assert "owner_user_id" not in captured + assert "owner_username" not in captured + list_users.assert_not_awaited() + + @pytest.mark.asyncio + async def test_owner_kwargs_preserve_existing_session_owner(self): + existing = SimpleNamespace( + owner_user_id="usr_existing", + owner_username="existing", + ) + + with patch("flocks.auth.service.AuthService.has_users", new=AsyncMock()) as has_users: + owner_kwargs = await resolve_channel_session_owner_kwargs(existing) + + assert owner_kwargs == { + "owner_user_id": "usr_existing", + "owner_username": "existing", + } + has_users.assert_not_awaited() + + # --------------------------------------------------------------------------- # 2. default_agent unification # --------------------------------------------------------------------------- diff --git a/tests/session/test_session_policy.py b/tests/session/test_session_policy.py index 180477abf..eaa698c5d 100644 --- a/tests/session/test_session_policy.py +++ b/tests/session/test_session_policy.py @@ -75,3 +75,22 @@ def test_can_read_local_shared_visible_to_all_local_users(): assert SessionPolicy.can_read(session, owner) is True assert SessionPolicy.can_read(session, admin) is True assert SessionPolicy.can_read(session, stranger) is True + + +def test_ownerless_session_is_not_marked_shared(): + session = _make_session(owner_user_id=None, owner_username=None) + assert SessionPolicy.is_shared(session) is False + + +def test_ownerless_session_admin_can_manage_but_member_cannot(): + admin = _make_user(user_id="usr_admin", username="admin", role="admin") + member = _make_user(user_id="usr_member", username="member", role="member") + session = _make_session(owner_user_id=None, owner_username=None) + + assert SessionPolicy.can_read(session, admin) is True + assert SessionPolicy.can_write(session, admin) is True + assert SessionPolicy.can_delete(session, admin) is True + + assert SessionPolicy.can_read(session, member) is False + assert SessionPolicy.can_write(session, member) is False + assert SessionPolicy.can_delete(session, member) is False diff --git a/tests/user_workflow/.gitignore b/tests/user_workflow/.gitignore new file mode 100644 index 000000000..1e08a2e68 --- /dev/null +++ b/tests/user_workflow/.gitignore @@ -0,0 +1,4 @@ +# 本地用户自定义 workflow 测试目录 +# 默认忽略目录内所有内容,仅保留当前 .gitignore 以便目录可被提交。 +* +!.gitignore