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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 只作用于当前话题。受理反馈、流式消息、最终
Expand Down
23 changes: 20 additions & 3 deletions loopx/capabilities/native_chat/conversation_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions loopx/capabilities/native_chat/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 23 additions & 13 deletions loopx/extensions/lark/conversation_identity.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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")
Expand All @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion loopx/extensions/lark/private_conversation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,15 @@
},
{
"site": "loopx/capabilities/native_chat/conversation_bindings.py::<module>.ChatConversationBindings.agent_observation::codec_read:load_registry#1",
"line": 223,
"line": 240,
"column": 20,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/capabilities/native_chat/conversation_bindings.py::<module>.ChatConversationBindings.goal_scope_ids::codec_read:load_registry#1",
"line": 145,
"line": 162,
"column": 40,
"kind": "codec_read",
"api": "load_registry",
Expand Down Expand Up @@ -519,7 +519,7 @@
},
{
"site": "loopx/chat_server.py::<module>.serve_chat._wake_goal_context::codec_read:load_registry#1",
"line": 1695,
"line": 1697,
"column": 20,
"kind": "codec_read",
"api": "load_registry",
Expand Down
Loading
Loading