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 e6de33119f..4421e297a0 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -45,8 +45,11 @@ existing group listeners. Replies recheck the original source under that App; they do not require group-member scopes for private messages. An existing group listener can also admit owner-private Chat through the same -verified App profile. Settings → Lark → owner private Chat reuses that profile; -aliases of the same App cannot start a competing listener. One consumer dispatches +verified App. Settings → Lark → owner private Chat resolves a verified profile +alias to the unique existing listener selector, including a legacy App ID, +instead of starting a competing listener. Inventory discovery supports both +profile names and App IDs; removal, retargeting and identity changes still reject. +One consumer dispatches explicit group events to the existing group admission and private events to Core owner admission. Rejection never falls through to the other audience. Group grants and review callbacks remain separate, and disconnecting either 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 cc2d025864..679c517f1e 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 @@ -34,7 +34,9 @@ Lark 页面选择一个非默认 App、当前可用工作区和宿主 executor 数量及恢复缺口;重新绑定不会把旧 Session 移到其它工作区。 既有群聊监听器也可通过同一个已核验 App profile 接入本人私聊。在设置 → Lark -中选择原 profile 和本人私聊;同 App 的别名不能另起竞争监听器。单个 consumer +中选择该 App 和本人私聊;经核验指向同一 App 的配置别名自动复用唯一的既有 +listener selector,包括旧版保存的 App ID,不另起竞争监听器。库存发现同时支持 +配置名和 App ID;移除、改绑或身份变化仍拒绝。单个 consumer 将明确的群聊事件交给既有群聊准入,将私聊交给 Core 本人准入;拒绝后不转投 另一受众。群聊授权和审阅回调保持独立,解绑一路保留另一路。HTTP/Core fixture 覆盖配置、本人未登录、App 不匹配和路由移除,不代表远程 Agent 投递或安装态 diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 9605688916..868911c095 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -83,6 +83,26 @@ does not overwrite it. A home-mismatch gate requires restoring the original native profile and thread, or resolving an explicit profile conflict; changing this variable is not a session-migration command. +Chat workspaces are also preserved through `LOOPX_CHAT_SCAN_PATHS_JSON` (a JSON +array of existing absolute directories). Reinstall recovers explicit +`--scan-path` / `--scan-root` selections from older plists. An unconfigured +managed installation now pins its bounded package workspace once instead of +silently following each new release directory. Set this variable explicitly +when selecting a durable project workspace; a missing original directory still +requires recovery and does not authorize a replacement workspace. + +The installer also pins Chat's resolved data directory as an explicit +`LOOPX_CHAT_RUNTIME_ROOT`. The upgrade activation guard can therefore check +the exact running store for unfinished requests before restarting it, instead +of refusing a registry-only service route or guessing the upgrader's default. + +中文:`LOOPX_CHAT_SCAN_PATHS_JSON` 保存聊天工作区,值为现有绝对目录的 JSON 数组。 +重装会保留旧 plist 中的 `--scan-path` / `--scan-root`;未配置的托管安装现在会固定 +首次选定的包目录,不再随升级后的安装目录改变会话身份。选择长期项目时显式设置 +该变量。原目录缺失仍需恢复,不自动改绑另一个目录。 +安装器也会将 Chat 实际使用的数据目录固定为 `LOOPX_CHAT_RUNTIME_ROOT`,让升级器 +能检查同一存储中的未完成请求后再重启,不因服务仅指定注册表而无法生效。 + The LaunchAgent keeps service execution `CODEX_HOME` separate from `LOOPX_CHAT_CODEX_HOME`. Set both when a coordinator's workers resume in a different existing home from its Chat app-server. Install/restart preserves the diff --git a/examples/macos-dashboard-launchagent-status-smoke.py b/examples/macos-dashboard-launchagent-status-smoke.py index 32ebd20062..6909cda325 100644 --- a/examples/macos-dashboard-launchagent-status-smoke.py +++ b/examples/macos-dashboard-launchagent-status-smoke.py @@ -454,7 +454,9 @@ def main() -> int: assert "--port 8767" in default_chat_plist, default_chat_plist assert "--replace-existing-loopx-chat" in default_chat_plist, default_chat_plist assert "--no-open" in default_chat_plist, default_chat_plist - assert "--runtime-root" not in shlex.split(plistlib.loads(chat_plist.read_bytes())["ProgramArguments"][2]) + from loopx.runtime_activation import _chat_runtime_root + pinned_runtime = _chat_runtime_root(chat_plist) + assert pinned_runtime == Path(plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_RUNTIME_ROOT"]) assert f"export CODEX_HOME={(home / '.codex').resolve()};" in default_chat_plist, default_chat_plist assert "export LOOPX_PYTHON=" in default_plist, default_plist assert "export LOOPX_PYTHON=" in default_chat_plist, default_chat_plist @@ -467,6 +469,16 @@ def main() -> int: assert "/loopx --registry" in default_plist, default_plist assert "/loopx-canary" not in default_plist, default_plist assert not (home / "Library" / "LaunchAgents" / "com.loopx.dashboard.plist").exists(), "retired dashboard LaunchAgent should not be installed" + # An implicit package workspace is pinned on first installation; a + # later release directory must not change existing conversation refs. + pinned = json.loads(plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_SCAN_PATHS_JSON"]) + assert pinned == [str((REPO_ROOT / "loopx").resolve())] + successor = home / "successor-release" + (successor / "loopx").mkdir(parents=True) + run_script(fake_bin, home, ["restart"], schema_version=2, + extra_env={"LOOPX_REPO_ROOT": str(successor)}) + assert json.loads(plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_SCAN_PATHS_JSON"]) == pinned + assert _chat_runtime_root(chat_plist) == pinned_runtime # KeepAlive restarts never re-enter this installer, so each agent # carries its own retention step for both of its streams. @@ -519,6 +531,12 @@ def main() -> int: assert str(custom_registry) in status_plist.read_text() status_command = shlex.split(plistlib.loads(status_plist.read_bytes())["ProgramArguments"][2]) assert [status_command[i + 1] for i, word in enumerate(status_command[:-1]) if word == "--scan-path"] == [str(p) for p in workspaces] + # Pre-environment plists still own their explicit CLI workspaces. + legacy = plistlib.loads(chat_plist.read_bytes()) + legacy["EnvironmentVariables"].pop("LOOPX_CHAT_SCAN_PATHS_JSON") + chat_plist.write_bytes(plistlib.dumps(legacy)) + run_script(fake_bin, home, ["restart"], schema_version=2) + assert json.loads(plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_SCAN_PATHS_JSON"]) == [str(p) for p in workspaces] before = chat_plist.read_bytes() rejected = run_script(fake_bin, home, ["install"], schema_version=2, extra_env={"LOOPX_CHAT_SCAN_PATHS_JSON": '["relative"]'}, check=False) diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 6b9e72dbe5..f3346dc7f3 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -67,7 +67,8 @@ def configure(self, *, transport_ref: str, project_ref: str, project_grant: str | None = None, goal_scope: str | None = None, audience: str | None = None, group_refs: list[str] | None = None, available_group_refs: list[str] | None = None, - expected_provider_ref: str | None = None) -> dict[str, Any]: + expected_provider_ref: str | None = None, + expected_operator_ref: str | None = None) -> dict[str, Any]: if goal_scope is not None and (context_kind != "steward" or goal_scope not in {"selected", "all_registered"}): raise ValueError("select a supported steward Goal scope") if project_grant is None: @@ -77,6 +78,8 @@ def configure(self, *, transport_ref: str, project_ref: str, observation = self.observe(transport_ref, audience=audience or "owner") if expected_provider_ref is not None and observation["provider_ref"] != expected_provider_ref: raise ValueError("the selected App changed before binding publication; verify it again") + if expected_operator_ref is not None and observation["operator_ref"] != expected_operator_ref: + raise ValueError("the selected owner changed before binding publication; verify it again") candidate = { "schema_version": "loopx_chat_conversation_binding_v0", "binding_id": uuid.uuid4().hex[:24], "transport_ref": transport_ref, diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index 782922be69..0319ab2d3b 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -96,7 +96,7 @@ def _private_conversation_connect(self) -> None: body = self._read_json() if set(body) - {"context_kind", "project_grant", "goal_scope", "audience", "group_ids"} != {"app_ref", "project_ref", "executor_endpoint_id"}: raise ValueError("select an App, authorized workspace and executor") - profile = str(body["app_ref"]) + profile = requested_profile = str(body["app_ref"]) snapshot = build_lark_goal_topic_runtime_snapshot( registry_path=self.server.registry_path, runtime_root_override=self.server.runtime_root_override) existing = _active_profile_configs(snapshot) @@ -118,9 +118,9 @@ def _private_conversation_connect(self) -> None: elif "group_ids" in body or "audience" in body: raise ValueError("unsupported conversation audience") from ...chat_lark_api import _app_identity_for_private_guard - group_apps = [str(config.get("bot_app_id") or _app_identity_for_private_guard( - other_profile, self._lark_runner(), self._require_lark_cli())) - for other_profile, config in existing.items()] + group_apps = {other_profile: _app_identity_for_private_guard( + other_profile, self._lark_runner(), self._require_lark_cli()) + for other_profile in existing} shared_group = existing.get(profile) if shared_group is not None: if body.get("audience", "owner") != "owner" or identity_ref( @@ -128,8 +128,15 @@ def _private_conversation_connect(self) -> None: profile, self._lark_runner(), self._require_lark_cli())) ) != observed["provider_ref"]: raise ValueError("shared listener requires the same verified App and a private owner audience") - elif any(identity_ref(app) == observed["provider_ref"] for app in group_apps): - raise ValueError("this App already has a group listener; select its existing profile to add private Chat") + else: + shared_profiles = [name for name, app in group_apps.items() + if identity_ref(app) == observed["provider_ref"]] + if shared_profiles: + if len(shared_profiles) != 1 or body.get("audience", "owner") != "owner": + raise ValueError("select one existing listener for the verified App and a private owner audience") + # Settings lists profile names; older group connections may + # retain an App-ID selector. Reuse that consumer identity. + profile = shared_profiles[0] # Compose the same transport configuration used by refresh before # publishing a binding. Failure must leave the Core grant untouched. _active_profile_configs({**snapshot, "private_profiles": { @@ -137,13 +144,18 @@ def _private_conversation_connect(self) -> None: endpoint = str(body["executor_endpoint_id"]) if not any(row["agent_id"] == endpoint and row["available"] for row in self.server.runtime_controller.capabilities()): raise ValueError("the selected executor is unavailable") + if profile != requested_profile: + current = self.server.runtime_controller.project_contexts.conversation_bindings.observe(requested_profile) + if any(current[key] != observed[key] for key in ("provider_ref", "operator_ref")): + raise ValueError("the selected App or owner changed before binding publication; verify it again") self.server.runtime_controller.project_contexts.conversation_bindings.configure( transport_ref=profile, project_ref=str(body["project_ref"]), executor_endpoint_id=endpoint, context_kind=str(body.get("context_kind", "project")), project_grant=str(body["project_grant"]) if "project_grant" in body else None, goal_scope=str(body["goal_scope"]) if "goal_scope" in body else None, audience=body.get("audience"), group_refs=group_refs, available_group_refs=available_group_refs, - expected_provider_ref=observed["provider_ref"]) + expected_provider_ref=observed["provider_ref"], + expected_operator_ref=observed.get("operator_ref")) self.server.lark_goal_topic_runtime.refresh() except (ValueError, OSError, KeyError) as exc: self._send_error(str(exc), status=400) diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index e5cf66079f..16f4abb128 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -87,6 +87,12 @@ def profiles(self) -> dict[str, dict[str, str]]: raise OSError("Lark profile configuration could not be read") apps = {str(row.get("name") or ""): str(row.get("appId") or "") for row in configured if isinstance(row, Mapping)} + # lark-cli also accepts an App ID as --profile. Legacy shared group + # listeners retain that selector; discover the same App without + # renaming its binding or opening a competing consumer. Explicit + # profile names take precedence over the App-ID fallback. + for app_id in list(apps.values()): + apps.setdefault(app_id, app_id) profiles = {} for row in bindings: app_id = apps.get(row["transport_ref"], "") diff --git a/scripts/macos-dashboard-launchagent.sh b/scripts/macos-dashboard-launchagent.sh index fe8b8aa012..61446b1238 100755 --- a/scripts/macos-dashboard-launchagent.sh +++ b/scripts/macos-dashboard-launchagent.sh @@ -196,10 +196,11 @@ PY } resolve_chat_scan_paths() { - "$1" - "$chat_plist" <<'PY' + "$1" - "$chat_plist" "$repo_root/loopx" <<'PY' import json import os import plistlib +import shlex import sys from pathlib import Path @@ -209,11 +210,23 @@ if selected is None and target.exists(): with target.open("rb") as stream: plist = plistlib.load(stream) selected = plist.get("EnvironmentVariables", {}).get("LOOPX_CHAT_SCAN_PATHS_JSON") + if selected is None: + args = plist.get("ProgramArguments", []) + words = shlex.split(args[2]) if len(args) == 3 and args[1] == "-c" else args + paths = [words[i + 1] for i, word in enumerate(words[:-1]) if word == "--scan-path"] + roots = [words[i + 1] for i, word in enumerate(words[:-1]) if word == "--scan-root"] + if paths or roots: + selected = json.dumps(paths or roots[-1:]) if selected is not None and (not isinstance(selected, str) or len(selected) > 16000): raise SystemExit("LoopX Chat workspace selection is too large") paths = json.loads(selected) if selected is not None else [] if not isinstance(paths, list) or len(paths) > 32: raise SystemExit("LoopX Chat workspace selection must be an array of at most 32 directories") +# The CLI's implicit package workspace changes with every release directory. +# Pin that bounded default once, just like an explicitly selected workspace, +# so reinstall does not silently replace existing conversation contexts. +if not paths: + paths = [sys.argv[2]] resolved = [] for item in paths: if not isinstance(item, str) or not item or any(ord(c) < 32 for c in item): @@ -318,7 +331,9 @@ PY } resolve_chat_options() { - "$1" - "$chat_plist" <<'PY' + ( + cd / + "$1" - "$chat_plist" "$registry" <<'PY' import json import math import os @@ -356,8 +371,17 @@ for variable, flag in ( if not value: raise SystemExit(f"{variable} has an empty installed argument") if value is None or value == "": - options[variable] = "" - continue # Retain the CLI's existing default, without pinning a new root. + if flag != "--runtime-root": + options[variable] = "" + continue + # Pin the same route Chat would select at installation. The updater + # can then fence that exact store without reinterpreting a later + # registry edit or inheriting the upgrader's default runtime. + from loopx.control_plane.projects.registry_codec import load_project_registry + from loopx.paths import resolve_runtime_root + registry_path = Path(sys.argv[2]) + configured = load_project_registry(registry_path) if registry_path.exists() else {} + value = str(resolve_runtime_root(configured, registry_path=registry_path)) if not isinstance(value, str) or len(value) > 4096 or any(ord(c) < 32 for c in value): raise SystemExit(f"{variable} must be a bounded string without control characters") if flag == "--runtime-root": @@ -381,6 +405,7 @@ if not isinstance(value, str) or len(value) > 256 or any(ord(c) < 32 for c in va options[variable] = value.strip() print(json.dumps(options)) PY + ) } write_plists() { diff --git a/tests/test_lark_private_compatibility.py b/tests/test_lark_private_compatibility.py index aa0ff44171..2ca252b82b 100644 --- a/tests/test_lark_private_compatibility.py +++ b/tests/test_lark_private_compatibility.py @@ -17,6 +17,53 @@ from loopx.extensions.lark.goal_topic_runtime_service import LarkGoalTopicRuntimeService +def test_app_id_selector_discovers_private_route_on_shared_group_listener(ordinary): # noqa: F811 + from test_lark_private_conversations import connect + from loopx.extensions.lark.goal_topic_runtime import poll_lark_goal_topic_profile_once + + _, runtime, provider, transport = connect(ordinary) + selector = "cli_notes_app" + binding = transport._binding("notes-app") + transport.bindings.disconnect(binding["binding_id"], expected_revision=transport.bindings.read()["revision"]) + # The CLI accepts the App ID, while its inventory reports the named profile. + provider.profile_apps[selector] = selector + def runner(args, cwd=None, timeout=None): + result = provider(args, cwd, timeout) + if args[1:] == ["profile", "list"]: + result["stdout"] = json.dumps([row for row in json.loads(result["stdout"]) + if row["name"] != selector]) + return result + transport.runner = runner + try: + transport.bindings.configure(transport_ref=selector, + project_ref=runtime.project_contexts.available()[0]["project_ref"], executor_endpoint_id="codex") + legacy = {"target_payload": {"targets": {"group": {"enabled": True, + "channel": {"chat_id": "oc_community"}, "identity": {"sender_profile": selector, + "cli_bin": "lark-cli", "bot_app_id": selector}}}}, + "binding_payloads": {"goal": {"bindings": {"goal": {"goal_id": "goal", "enabled": True, + "provider": "lark", "target_ref": "group", "topic": {"root_message_id": "om_root"}}}}}} + profiles = transport.profiles() + assert profiles[selector]["consumer_ref"] == hashlib.sha256(selector.encode()).hexdigest()[:32] + event = provider.event(selector, "private_status", "/status") + result = poll_lark_goal_topic_profile_once(profile=selector, + snapshot={**legacy, "private_profiles": profiles}, runtime_root=transport.runtime_root, + answer=lambda **_: pytest.fail("private input must not reach group execution"), + consume_runner=lambda _: {"returncode": 0, "stdout": json.dumps(event)}, + private_admitter=transport.admit) + assert result["event_statuses"] == ["command_recorded"] + # A removed App must not remain discoverable through its ID selector. + provider.profile_apps.pop("notes-app") + assert selector not in transport.profiles() + # An explicit profile with the same spelling must not be redirected + # to a different App merely because that App exists elsewhere. + provider.profile_apps["notes-app"] = selector + provider.profile_apps[selector] = "cli_replacement" + transport.runner = provider + assert selector not in transport.profiles() + finally: + runtime.close() + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cross-process flock contract") @pytest.mark.parametrize("shared_private", [False, True]) def test_legacy_consumer_stays_standby_until_original_profile_owner_exits(tmp_path, monkeypatch, shared_private): @@ -183,15 +230,33 @@ def poll(snapshot): @pytest.mark.parametrize("stored_app,retarget", [(None, False), ("cli_notes_app", False), - ("cli_other_fixture", False), (None, True)]) -def test_private_http_setup_shares_existing_group_profile_without_changing_its_grants(ordinary, monkeypatch, stored_app, retarget): # noqa: F811 + ("cli_other_fixture", False), (None, True), (None, "owner")]) +@pytest.mark.parametrize("app_id_selector", [False, True]) +def test_private_http_setup_shares_existing_group_profile_without_changing_its_grants(ordinary, monkeypatch, stored_app, retarget, app_id_selector): # noqa: F811 from test_lark_private_conversations import connect from loopx.extensions.lark.cli_resolution import LarkCliResolution from loopx.extensions.lark.goal_topic_runtime import _active_profile_configs store, runtime, provider, transport = connect(ordinary) + selector = "cli_notes_app" if app_id_selector else "notes-app" + if app_id_selector: + binding = transport._binding("notes-app") + transport.bindings.disconnect(binding["binding_id"], expected_revision=transport.bindings.read()["revision"]) + provider.profile_apps[selector] = "cli_notes_app" + observe = transport.bindings.observe + def same_cli_identity(profile, **kwargs): + return {**observe("notes-app" if profile == selector else profile, **kwargs), + "transport_ref": profile} + transport.bindings.observe = same_cli_identity + def named_inventory(args, cwd=None, timeout=None): + result = provider(args, cwd, timeout) + if args[1:] == ["profile", "list"]: + result["stdout"] = json.dumps([row for row in json.loads(result["stdout"]) + if row["name"] != selector]) + return result + transport.runner = named_inventory legacy = {"target_payload": {"targets": {"group": {"enabled": True, - "channel": {"chat_id": "oc_community"}, "identity": {"sender_profile": "notes-app", + "channel": {"chat_id": "oc_community"}, "identity": {"sender_profile": selector, "cli_bin": "lark-cli", "bot_app_id": "cli_notes_app"}}}}, "binding_payloads": {"goal": {"bindings": {"goal": {"goal_id": "goal", "enabled": True, "provider": "lark", "target_ref": "group", "topic": {"root_message_id": "om_root"}}}}}} @@ -229,7 +294,12 @@ def post(body): if retarget: capabilities = runtime.capabilities def retargeted_capabilities(): - provider.profile_apps["notes-app"] = "cli_replacement" + if retarget == "owner": + previous = transport.bindings.observe + transport.bindings.observe = lambda profile, **kwargs: { + **previous(profile, **kwargs), "operator_ref": "f" * 24} + else: + provider.profile_apps["notes-app"] = "cli_replacement" return capabilities() monkeypatch.setattr(runtime, "capabilities", retargeted_capabilities) body = {"app_ref": "notes-app", "project_ref": runtime.project_contexts.available()[0]["project_ref"], @@ -246,9 +316,9 @@ def retargeted_capabilities(): assert transport.bindings.read() == before return assert status == 200, result - saved = next(row for row in result["connections"] if row["app_ref"] == "notes-app") + saved = next(row for row in result["connections"] if row["app_ref"] == selector) assert saved["audience"] == "owner" and saved["context_kind"] == "steward" - assert "notes-app" in _active_profile_configs(snapshot()) + assert selector in _active_profile_configs(snapshot()) service.close() # Reload the committed Core binding and compose the production listener # again. A successful settings response must survive cold discovery. @@ -256,7 +326,7 @@ def retargeted_capabilities(): runtime_controller=runtime, profile_poller=lambda _profile, stop: stop.wait()) server.lark_goal_topic_runtime = service service.refresh() - assert set(service.health_snapshot()) == {"notes-app", "steward-app"} + assert set(service.health_snapshot()) == {selector, "steward-app"} assert post({**body, "audience": "group", "group_ids": ["oc_community"]})[0] == 400 assert json.dumps(legacy, sort_keys=True) == frozen assert not provider.writes and not store.list_sessions()