diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 21a08bfa27..caa9eeea2f 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -81,6 +81,14 @@ the allowlist at configuration time. This is an audience option on the existing binding, not a new Goal Channel, listener, queue or model runner. One App retains one binding owner. Existing private bindings keep their audience and host grants. +New group bindings verify the selected Bot's App identity; they do not require +a personal user login in that App profile. The group principal is App-scoped and +never supplies private-owner authority. Private project/steward connections still +require both the verified App and its independently verified user. Existing group +bindings that used a user principal retain that exact identity and native home; +an explicit disconnect/reconnect selects the new App principal and requires a new +isolated home/login. Polling or a user's login/logout never migrates a binding. + A human starts a topic by mentioning the Bot. Admission reads the exact provider message and, for a continuation, its original root; event text cannot substitute the root or mention. Members in that topic share its Session and FIFO; different diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index e17b4baeac..74c23c98d4 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -60,6 +60,11 @@ extension,typed binding owner 继续保持 provider-neutral。 listener、队列或模型 runner。一个 App 仍只有一个 binding owner,已有私聊绑定保留 原受众及宿主权限。 +新群绑定只核验选定 Bot 的 App 身份,不要求同一 profile 登录个人用户。群 principal +限定于 App,不提供本人私聊权限;个人项目和管家私聊仍同时要求独立核验 App 与用户。 +旧群绑定若使用用户 principal,继续保留确切身份和原生 home;明确解绑再连接才选择 +新的 App principal,并需要新的隔离 home/登录。轮询或用户登录、退出不会迁移绑定。 + 成员 @Bot 开始话题。受理时读取 provider 原消息,续聊还读取原话题根;事件文字不能 替换根消息或提及证据。同一话题的成员共享 Session 与 FIFO,不同话题、群和 App 使用独立 Session。status、stop、new 只作用于当前话题。受理反馈、流式消息、最终 diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 88e28e9a13..4da6b1860d 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -19,12 +19,29 @@ class ChatConversationBindings: def __init__(self, *, root: Path, project_contexts: Any, - observe: Callable[[str], dict[str, Any]]) -> None: + observe: Callable[[str], dict[str, Any]], + observe_group: Callable[[str], dict[str, Any]] | None = None) -> None: self.path = root / "conversation-bindings.json" self.projects = project_contexts - self.observe = observe + self._observe_owner = observe + self._observe_group = observe_group self.controller: Any | None = None + def observe(self, transport_ref: str, *, audience: str | None = None) -> dict[str, Any]: + row = next((item for item in self.read()["bindings"] if item["transport_ref"] == transport_ref), {}) + if audience is None: + audience = row.get("audience", "owner") + if audience == "group" and self._observe_group is not None: + observed = self._observe_group(transport_ref) + # Earlier group grants used the verified user's principal. Keep + # that exact identity/home until an explicit disconnect/reconnect; + # changing it during a poll would invalidate resumed Sessions. + if (row.get("audience") == "group" and row.get("provider_ref") == observed["provider_ref"] + and row.get("operator_ref") != observed["operator_ref"]): + return self._observe_owner(transport_ref) + return observed + return self._observe_owner(transport_ref) + def read(self) -> dict[str, Any]: if not self.path.exists(): return {"schema_version": "loopx_chat_conversation_bindings_v0", "revision": 0, "bindings": []} @@ -52,7 +69,7 @@ def configure(self, *, transport_ref: str, project_ref: str, project_grant = self.projects.workspace_grant if context_kind == "project" and executor_endpoint_id == "codex" else "workspace_read" if project_grant not in {"workspace_read", "workspace_write"} or (context_kind != "project" and project_grant != "workspace_read"): raise ValueError("workspace write authorization is only available for project Chat") - observation = self.observe(transport_ref) + observation = self.observe(transport_ref, audience=audience or "owner") candidate = { "schema_version": "loopx_chat_conversation_binding_v0", "binding_id": uuid.uuid4().hex[:24], "transport_ref": transport_ref, diff --git a/loopx/capabilities/native_chat/transports.py b/loopx/capabilities/native_chat/transports.py index 1a8c8cfe3c..d0212b73b3 100644 --- a/loopx/capabilities/native_chat/transports.py +++ b/loopx/capabilities/native_chat/transports.py @@ -26,11 +26,12 @@ def close_transports(transports: Sequence[Any]) -> None: def install_conversation_transports(server: Any, *, observe_default: Callable[[str], dict[str, Any]], - factories: Sequence[Callable[[Any], Any]]) -> None: + factories: Sequence[Callable[[Any], Any]], + observe_group: Callable[[str], dict[str, Any]] | None = None) -> None: if not factories: server.runtime_controller.project_contexts.conversation_bindings = ChatConversationBindings( root=server.chat_store.root, project_contexts=server.runtime_controller.project_contexts, - observe=observe_default) + observe=observe_default, observe_group=observe_group) return transports = [] try: @@ -39,7 +40,8 @@ def install_conversation_transports(server: Any, *, observe_default: Callable[[s server.conversation_transports = ChatConversationTransports( observe_default=observe_default, transports=transports) bindings = ChatConversationBindings(root=server.chat_store.root, - project_contexts=server.runtime_controller.project_contexts, observe=server.conversation_transports.observe) + project_contexts=server.runtime_controller.project_contexts, observe=server.conversation_transports.observe, + observe_group=observe_group) server.runtime_controller.project_contexts.conversation_bindings = bindings server.conversation_transports.bindings = bindings except Exception: diff --git a/loopx/chat_server.py b/loopx/chat_server.py index fde59be352..7fef1f807d 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1644,6 +1644,8 @@ def serve_chat( ) from .capabilities.native_chat.transports import install_conversation_transports install_conversation_transports(server, factories=external_conversation_factories, + observe_group=lambda profile: observe_lark_conversation_identity(profile=profile, runner=server.lark_runner, + cli_bin=server.lark_cli_resolution.command or "lark-cli", audience="group"), observe_default=lambda profile: observe_lark_conversation_identity(profile=profile, runner=server.lark_runner, cli_bin=server.lark_cli_resolution.command or "lark-cli")) private_transport = LarkPrivateConversations(controller=server.runtime_controller, runtime_root=runtime_root, diff --git a/loopx/extensions/lark/conversation_identity.py b/loopx/extensions/lark/conversation_identity.py index 5d3a7ffc89..51cc35fc0e 100644 --- a/loopx/extensions/lark/conversation_identity.py +++ b/loopx/extensions/lark/conversation_identity.py @@ -1,7 +1,8 @@ -"""Lark-specific identity observation for a Core-owned private conversation. +"""Lark-specific identity observation for a Core-owned conversation. -Each non-default profile independently verifies its App and logged-in owner. -Core receives only opaque, App-scoped references. Tokens remain in lark-cli. +Each non-default profile independently verifies its App; private audiences +also verify the logged-in owner. Core receives only opaque, App-scoped +references. Tokens remain in lark-cli. """ from __future__ import annotations @@ -21,7 +22,9 @@ def identity_ref(*values: str) -> str: def observe_lark_conversation_identity(*, profile: str, runner: CommandRunner, - cli_bin: str) -> dict[str, Any]: + cli_bin: str, audience: str = "owner") -> dict[str, Any]: + if audience not in {"owner", "group"}: + raise ValueError("unsupported conversation audience") profile = _profile_ref(profile) if profile.casefold() == "default": raise ValueError("private conversations require an explicit non-default App profile") @@ -34,20 +37,27 @@ def observe_lark_conversation_identity(*, profile: str, runner: CommandRunner, app_id = str(payload.get("appId") or "") if result.get("returncode") != 0 or not APP_ID_PATTERN.fullmatch(app_id): raise ValueError("the selected App identity could not be verified") - if not isinstance(bot, Mapping) or not isinstance(owner, Mapping): - raise ValueError("verify this App and its owner independently before binding private Chat") - if not all(row.get("available") is True and row.get("verified") is True - for row in [bot, owner]): + if not isinstance(bot, Mapping) or not (bot.get("available") is True and bot.get("verified") is True): + raise ValueError("the selected App identity could not be verified") + provider_ref = identity_ref(app_id) + # A locally configured group grant belongs to this verified App. Its + # principal never identifies a human or supplies private-owner authority. + if audience == "group": + operator_ref = identity_ref(provider_ref, "group") + elif not isinstance(owner, Mapping): raise ValueError("verify this App and its owner independently before binding private Chat") - owner_id = str(owner.get("openId") or "") - if not OPEN_ID_PATTERN.fullmatch(owner_id): - raise ValueError("the selected App has no verified owner identity") + else: + if not (owner.get("available") is True and owner.get("verified") is True): + raise ValueError("verify this App and its owner independently before binding private Chat") + owner_id = str(owner.get("openId") or "") + if not OPEN_ID_PATTERN.fullmatch(owner_id): + raise ValueError("the selected App has no verified owner identity") + operator_ref = identity_ref(provider_ref, owner_id) bot_open_id = str(bot.get("openId") or "") if not OPEN_ID_PATTERN.fullmatch(bot_open_id): bot_open_id = "" - provider_ref = identity_ref(app_id) return {"transport_ref": profile, "provider_ref": provider_ref, - "operator_ref": identity_ref(provider_ref, owner_id), "verified": True, + "operator_ref": operator_ref, "verified": True, "consumer_ref": hashlib.sha256(app_id.encode("utf-8")).hexdigest()[:32], "bot_display_name": str(bot.get("appName") or ""), "bot_app_id": app_id, "bot_open_id": bot_open_id} diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index 03cdd3ac6a..b07ffb697b 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -41,7 +41,8 @@ def _private_conversation_connect(self) -> None: existing = _active_profile_configs(build_lark_goal_topic_runtime_snapshot( registry_path=self.server.registry_path, runtime_root_override=self.server.runtime_root_override)) from .conversation_identity import identity_ref - observed = self.server.runtime_controller.project_contexts.conversation_bindings.observe(profile) + observed = self.server.runtime_controller.project_contexts.conversation_bindings.observe( + profile, audience=body.get("audience") or "owner") group_refs, available_group_refs = None, None if body.get("audience") == "group": from .goal_topic_connections import list_lark_group_chats, LarkGroupChatLookupError diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index a0ee33f6f3..b31264f47d 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -287,7 +287,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.agent_observation::codec_read:load_registry#1", - "line": 223, + "line": 240, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -295,7 +295,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.goal_scope_ids::codec_read:load_registry#1", - "line": 145, + "line": 162, "column": 40, "kind": "codec_read", "api": "load_registry", @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1695, + "line": 1697, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 56b0217df9..43df7d2685 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -26,6 +26,8 @@ def __init__(self): self.reaction_creates = [] self.fail_reaction_create = False self.fail_reaction_delete = False + self.owner_ready = True + self.bot_ready = True self.profile_apps = {profile: f"cli_{profile.replace('-', '_')}" for profile in ["notes-app", "steward-app"]} @@ -58,10 +60,10 @@ def __call__(self, args, cwd=None, timeout=None): for profile, app_id in self.profile_apps.items()]), "stderr": ""} profile = args[args.index("--profile") + 1] if "auth" in args: - data = {"ok": True, "appId": f"cli_{profile.replace('-', '_')}", "identities": { - "bot": {"available": True, "verified": True, "appName": profile, + data = {"ok": True, "appId": self.profile_apps[profile], "identities": { + "bot": {"available": self.bot_ready, "verified": self.bot_ready, "appName": profile, "openId": f"ou_{profile.replace('-', '_')}_bot"}, - "user": {"available": True, "verified": True, "openId": f"ou_{profile.replace('-', '_')}"}}} + "user": {"available": self.owner_ready, "verified": self.owner_ready, "openId": f"ou_{profile.replace('-', '_')}"}}} elif "+chat-list" in args: data = {"ok": True, "data": {"chats": [{"chat_id": chat, "name": title} for chat, title in [("oc_community", "Community trial"), ("oc_second", "Second trial")]]}} @@ -128,7 +130,8 @@ def connect(fixture): store, runtime, contexts, _, _, _, _ = fixture provider = Provider() bindings = ChatConversationBindings(root=store.root, project_contexts=contexts, - observe=lambda profile: observe_lark_conversation_identity(profile=profile, runner=provider, cli_bin="lark-cli")) + observe=lambda profile: observe_lark_conversation_identity(profile=profile, runner=provider, cli_bin="lark-cli"), + observe_group=lambda profile: observe_lark_conversation_identity(profile=profile, runner=provider, cli_bin="lark-cli", audience="group")) contexts.conversation_bindings = bindings for profile in ["notes-app", "steward-app"]: bindings.configure(transport_ref=profile, project_ref=contexts.available()[0]["project_ref"], executor_endpoint_id="codex") @@ -344,7 +347,8 @@ def test_group_status_and_recipient_commands_cannot_expose_private_sessions(ordi runtime.close() -def test_http_group_setup_observes_membership_reads_back_selection_and_rejects_widening(ordinary, monkeypatch): # noqa: F811 +@pytest.mark.parametrize("owner_ready", [True, False]) +def test_http_group_setup_observes_membership_reads_back_selection_and_rejects_widening(ordinary, monkeypatch, owner_ready): # noqa: F811 from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.extensions.lark.cli_resolution import LarkCliResolution import loopx.chat_lark_api as api @@ -376,6 +380,7 @@ def request(path, body=None): path = "/api/chat/lark/private-conversations" body = {"app_ref": "notes-app", "project_ref": runtime.project_contexts.available()[0]["project_ref"], "executor_endpoint_id": "codex", "audience": "group", "group_ids": ["oc_community"]} + provider.owner_ready = owner_ready try: original = transport.bindings.read() for bad in [{"group_ids": ["oc_not_selected"]}, {"group_ids": []}, @@ -394,6 +399,10 @@ def request(path, body=None): assert request(path, body)[1]["revision"] == response["revision"] assert refreshes == [True, True] assert not store.list_sessions() and not provider.writes + if not owner_ready: + frozen = transport.bindings.read() + assert request(path, {key: value for key, value in body.items() if key not in {"audience", "group_ids"}})[0] == 400 + assert transport.bindings.read() == frozen finally: server.shutdown() server.server_close() @@ -401,6 +410,70 @@ def request(path, body=None): runtime.close() +def test_bot_only_group_continues_after_user_logout_but_rejects_private_and_app_drift(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + provider.owner_ready = False + root = provider.topic("bot-only-root", "Explain the project") + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + first = finish_group_turn(runtime, transport, message=root["content"]) + home = store.load_session(first["session_id"])["codex_home"] + # Signing in a user later must not change this App-owned principal/home. + provider.owner_ready = True + follow = provider.topic("bot-only-follow", "Compare the config", root=root["message_id"]) + assert transport.admit("notes-app", follow)["status"] == "durably_accepted" + second = finish_group_turn(runtime, transport, message=follow["content"]) + assert second["session_id"] == first["session_id"] + assert store.load_session(second["session_id"])["codex_home"] == home + private = provider.event("notes-app", "private-for-group", "List personal tasks") + assert transport.admit("notes-app", private)["status"] == "audience_rejected" + provider.profile_apps["notes-app"] = "cli_rotated_app" + changed = provider.topic("rotated-app", "Explain config", root=root["message_id"]) + assert transport.admit("notes-app", changed)["status"] == "audience_rejected" + finally: + runtime.close() + + +def test_group_identity_requires_verified_bot_and_private_identity_requires_verified_user(): + provider = Provider() + provider.owner_ready = False + group = observe_lark_conversation_identity(profile="notes-app", runner=provider, cli_bin="lark-cli", audience="group") + assert group["operator_ref"] != identity_ref(group["provider_ref"], "ou_notes_app") + with pytest.raises(ValueError, match="owner independently"): + observe_lark_conversation_identity(profile="notes-app", runner=provider, cli_bin="lark-cli") + provider.bot_ready = False + with pytest.raises(ValueError, match="App identity"): + observe_lark_conversation_identity(profile="notes-app", runner=provider, cli_bin="lark-cli", audience="group") + + +def test_legacy_group_principal_and_native_home_are_not_migrated_during_observation(ordinary): # noqa: F811 + store, runtime, provider, transport = connect_group(ordinary) + try: + group_observer = transport.bindings._observe_group + transport.bindings._observe_group = None + binding = next(row for row in transport.bindings.read()["bindings"] if row["transport_ref"] == "notes-app") + transport.bindings.configure(transport_ref="notes-app", project_ref=binding["project_ref"], + executor_endpoint_id="codex", audience="group", group_refs=binding["group_refs"], + available_group_refs=binding["group_refs"]) + transport.bindings._observe_group = group_observer + frozen = transport.bindings.read() + root = provider.topic("legacy-group-root", "Explain the project") + assert transport.admit("notes-app", root)["status"] == "durably_accepted" + first = finish_group_turn(runtime, transport, message=root["content"]) + home = store.load_session(first["session_id"])["codex_home"] + follow = provider.topic("legacy-group-follow", "Compare config", root=root["message_id"]) + assert transport.admit("notes-app", follow)["status"] == "durably_accepted" + second = finish_group_turn(runtime, transport, message=follow["content"]) + assert first["session_id"] == second["session_id"] + assert store.load_session(second["session_id"])["codex_home"] == home + assert transport.bindings.read() == frozen + provider.owner_ready = False + assert transport.admit("notes-app", provider.topic("legacy-logout", "Compare", root=root["message_id"]))["status"] == "audience_rejected" + assert transport.bindings.read() == frozen + finally: + runtime.close() + + def test_native_private_admission_queue_other_app_stop_and_verified_delivery(ordinary): # noqa: F811 store, runtime, provider, transport = connect(ordinary) try: