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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 投递或安装态
Expand Down
20 changes: 20 additions & 0 deletions docs/integrations/session-runtime-control-plane-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion examples/macos-dashboard-launchagent-status-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion loopx/capabilities/native_chat/conversation_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
26 changes: 19 additions & 7 deletions loopx/extensions/lark/private_conversation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -118,32 +118,44 @@ 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(
str(shared_group.get("bot_app_id") or _app_identity_for_private_guard(
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": {
profile: {**observed, "audience": body.get("audience", "owner")}}})
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)
Expand Down
6 changes: 6 additions & 0 deletions loopx/extensions/lark/private_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"], "")
Expand Down
33 changes: 29 additions & 4 deletions scripts/macos-dashboard-launchagent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -318,7 +331,9 @@ PY
}

resolve_chat_options() {
"$1" - "$chat_plist" <<'PY'
(
cd /
"$1" - "$chat_plist" "$registry" <<'PY'
import json
import math
import os
Expand Down Expand Up @@ -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":
Expand All @@ -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() {
Expand Down
Loading
Loading