diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 5a7fde15f5..9e5717b8d8 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2302,6 +2302,7 @@ export async function fetchLarkGroupChats(appRef: string, query?: string) { export type LarkGoalConnection = { conversation_kind?: "goal" | "manager"; turn_trigger?: "addressed" | "human_messages"; + read_goal_scope?: "all_registered" | "selected" | null; agent_id: string | null; connection_id: string; app_label: string; @@ -2334,6 +2335,7 @@ const larkConnectionsSchema = z.object({ connections: z.array(z.object({ conversation_kind: z.enum(["goal", "manager"]).default("goal"), turn_trigger: z.enum(["addressed", "human_messages"]).default("addressed"), + read_goal_scope: z.enum(["all_registered", "selected"]).nullable().default("selected"), agent_id: z.string().nullable().default(null), connection_id: z.string(), app_label: z.string(), @@ -2381,6 +2383,7 @@ export async function fetchLarkConnections() { export async function connectLarkGoalTopic(options: { conversationKind?: "goal" | "manager"; turnTrigger?: "addressed" | "human_messages"; + readGoalScope?: "all_registered" | "selected"; agentBindings?: Array<{ agentId: string; appRef: string }>; agentId?: string; appRef?: string; @@ -2409,6 +2412,7 @@ export async function connectLarkGoalTopic(options: { ...(options.connectionId ? { connection_id: options.connectionId } : {}), conversation_kind: options.conversationKind ?? "goal", ...(options.turnTrigger ? { turn_trigger: options.turnTrigger } : {}), + ...(options.readGoalScope ? { read_goal_scope: options.readGoalScope } : {}), capture_scope: options.captureScope, chat_id: options.chatId, chat_name: options.chatName, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 7b136fc86e..0b75f11f91 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -820,6 +820,11 @@ const en = { "lark.triggerAddressed": "Only when mentioned or replied to", "lark.triggerHuman": "Respond to group members without @", "lark.triggerDescription": "Everyone in this connected group can start a conversation. Bot messages and historical backfill stay as context. Requires the Lark app permission to receive all group messages; tool and delegation permissions stay unchanged.", + "lark.readGoalScope": "Goal evidence access", + "lark.readAllGoals": "All registered Goals, including new ones", + "lark.readSelectedGoals": "Keep the selected Goal scope", + "lark.readScopeUnavailable": "Goal read scope unavailable", + "lark.readGoalScopeDescription": "Applies to everyone authorized to use this manager conversation. This reads local Goal evidence; it grants no execution, publishing or remote-host access. Existing connections retain their selected scope until you change it.", "lark.managerConversationDescription": "The built-in manager responds as you chat and delegates long-running work to background Agents. Group and private conversations keep separate histories.", "lark.ingressLegacy": "Upgrade available", "lark.ingressLegacyDescription": "Save this connection to upgrade to the Agent inbox. Existing messages remain in the same Topic.", @@ -2392,6 +2397,11 @@ const zhCN: Record = { "lark.triggerAddressed": "仅 @ 或回复管家时", "lark.triggerHuman": "群成员直接发消息,无需 @", "lark.triggerDescription": "此群所有成员都可以发起对话;机器人消息和补拉历史只作背景。需要飞书应用具备接收群内所有消息的权限;工具权限和委派授权保持不变。", + "lark.readGoalScope": "Goal 证据读取范围", + "lark.readAllGoals": "全部已注册 Goal(含后续新增)", + "lark.readSelectedGoals": "保留选定 Goal 范围", + "lark.readScopeUnavailable": "Goal 读取范围不可用", + "lark.readGoalScopeDescription": "适用于获准使用此管家对话的所有成员。只读取本机 Goal 证据,不授予执行、发布或远程主机权限。旧连接保留选定范围,直到你明确修改。", "lark.managerConversationDescription": "内置管家即时回应对话,长任务交给后台 Agent。群聊与私聊分别保存上下文。", "lark.ingressLegacy": "待升级", "lark.ingressLegacyDescription": "保存连接即可升级为 Agent 异步收件箱,已有消息保留在原 Topic 中。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index cade2c603e..7839a36b02 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -221,6 +221,7 @@ export function LarkSettingsPage({ const [chatLoading, setChatLoading] = useState(false); const [chatLoadError, setChatLoadError] = useState(null); const [turnTrigger, setTurnTrigger] = useState<"addressed" | "human_messages">("addressed"); + const [readGoalScope, setReadGoalScope] = useState<"all_registered" | "selected">("all_registered"); const [captureScope, setCaptureScope] = useState("addressed_only"); const [conversationKind, setConversationKind] = useState<"goal" | "manager">(focusGoalConnection && initialGoalId ? "goal" : "manager"); const [ingressMode, setIngressMode] = useState("async_inbox"); @@ -404,6 +405,7 @@ export function LarkSettingsPage({ setAgentAppRefs({}); setCaptureScope("addressed_only"); setTurnTrigger("addressed"); + setReadGoalScope("all_registered"); setIngressMode("async_inbox"); setReplyMode("topic_reply"); setChatQuery(""); @@ -424,6 +426,7 @@ export function LarkSettingsPage({ setAgentAppRefs({}); setCaptureScope(connection.capture_scope); setTurnTrigger(connection.turn_trigger ?? "addressed"); + setReadGoalScope(connection.read_goal_scope ?? "selected"); setIngressMode(connection.ingress_mode === "direct_session" ? "async_inbox" : connection.ingress_mode); setReplyMode(connection.reply_mode); setChatQuery(connection.chat_name); @@ -492,7 +495,7 @@ export function LarkSettingsPage({ chatName: selectedChat!.chat_name, }), conversationKind, - ...(conversationKind === "manager" ? { turnTrigger } : {}), + ...(conversationKind === "manager" ? { turnTrigger, readGoalScope } : {}), captureScope: conversationKind === "manager" ? "addressed_only" as const : captureScope, goalId, incomingMode: captureScope === "configured_chat_all" ? "all" as const : "mentions" as const, @@ -590,7 +593,7 @@ export function LarkSettingsPage({ {t("lark.historyPermission")} ) : null} - {connection.goal_title}# {connection.topic_name} + {connection.goal_title}# {connection.topic_name}{connection.conversation_kind === "manager" ? {t(connection.read_goal_scope === "all_registered" ? "lark.readAllGoals" : connection.read_goal_scope === null ? "lark.readScopeUnavailable" : "lark.readSelectedGoals")} : null} {connection.conversation_kind === "manager" ? t(connection.turn_trigger === "human_messages" ? "lark.triggerHuman" : "lark.triggerAddressed") : connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")} {connection.conversation_kind === "manager" ? t("lark.managerConversation") : ingressPresentation(connection.ingress_mode, t).label}{connection.conversation_kind === "manager" ? t("lark.managerConversationDescription") : connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail} @@ -631,6 +634,7 @@ export function LarkSettingsPage({ {conversationKind === "manager" ? : null} + {conversationKind === "manager" ? : null} {conversationKind === "goal" ? <>
{t("lark.agentIngress")}
{(["live_steering", "session_queue", "async_inbox"] as const).map((mode) => { const presentation = ingressPresentation(mode, t); return ; })}
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 86ee97a18f..43a95153d6 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1503,7 +1503,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu const existing = runtime.larkConnections.find((item) => item.connection_id === body.connection_id && item.goal_id === body.goal_id); if (!existing || body.app_ref || body.chat_id || body.agent_bindings) throw new Error("Editing must select the stored connection without replacing its identity"); if (body.execute) { - Object.assign(existing, { turn_trigger: body.turn_trigger ?? existing.turn_trigger, agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope }); + Object.assign(existing, { read_goal_scope: body.read_goal_scope ?? existing.read_goal_scope, turn_trigger: body.turn_trigger ?? existing.turn_trigger, agent_id: body.agent_id, ingress_mode: body.ingress_mode, capture_scope: body.capture_scope }); state.larkWrites.push({ ...body }); } await route.fulfill({ contentType: "application/json", json: { ok: true, status: body.execute ? "connected" : "preview_ready" }, status: 200 }); @@ -1522,6 +1522,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu agent_id: body.conversation_kind === "manager" ? "loopx-manager" : binding.agent_id ?? null, conversation_kind: body.conversation_kind ?? "goal", turn_trigger: body.turn_trigger ?? "addressed", + read_goal_scope: body.read_goal_scope ?? "all_registered", connection_id: connectionId, app_label: binding.app_ref === "mew-research" ? "LoopX Research" : "LoopX Mew", app_ref: binding.app_ref, chat_name: body.chat_name, enabled: true, capture_scope: body.capture_scope, diff --git a/examples/personal-workspace-browser/steward-group-trigger.mjs b/examples/personal-workspace-browser/steward-group-trigger.mjs index 68e51a0a59..8de773cf4d 100644 --- a/examples/personal-workspace-browser/steward-group-trigger.mjs +++ b/examples/personal-workspace-browser/steward-group-trigger.mjs @@ -14,6 +14,8 @@ export const stewardGroupTriggerScenario = { const dialog = page.getByRole("dialog", {name: "连接 Lark App"}); await dialog.getByRole("option", {name: "Product group"}).waitFor({state: "attached"}); await dialog.getByLabel("群聊").selectOption({label: "Product group"}); + const readScope = dialog.getByLabel("Goal 证据读取范围"); + if (await readScope.inputValue() !== "all_registered") throw new Error("New steward does not default to all registered evidence"); const trigger = dialog.getByLabel("何时回应"); if (await trigger.inputValue() !== "addressed") throw new Error("Legacy mention default changed"); await trigger.selectOption("human_messages"); @@ -23,6 +25,7 @@ export const stewardGroupTriggerScenario = { const row = page.locator(".personal-lark-table-row", {hasText: "Product group"}); await row.getByText("群成员直接发消息,无需 @", {exact: true}).waitFor(); await row.getByText("监听已连接,尚无新消息验证这条连接。请在群里直接发一条任务,无需 @;若没有收到事件,再检查 im.message.receive_v1 和接收群内所有消息的权限。", {exact: true}).waitFor(); + if (api.larkWrites[0]?.read_goal_scope !== "all_registered") throw new Error("Evidence scope not saved"); if (api.larkWrites.length !== 1 || api.larkWrites[0].turn_trigger !== "human_messages") throw new Error("Trigger not saved"); const connection = api.larkConnections[0]; for (const [reason, detail] of [ @@ -46,10 +49,14 @@ export const stewardGroupTriggerScenario = { await page.setViewportSize({width: 390, height: 844}); await editor.getByLabel("何时回应").scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "steward-group-trigger-mobile.png"), animations: "disabled"}); + if (await editor.getByLabel("Goal 证据读取范围").inputValue() !== "all_registered") throw new Error("Evidence scope not read back"); + await editor.getByLabel("Goal 证据读取范围").selectOption("selected"); await editor.getByLabel("何时回应").selectOption("addressed"); await editor.getByRole("button", {name: "保存连接", exact: true}).click(); await editor.waitFor({state: "hidden"}); await row.getByText("仅 @ 或回复管家时", {exact: true}).waitFor(); + await row.getByText("保留选定 Goal 范围", {exact: true}).waitFor(); + if (api.larkWrites[1]?.read_goal_scope !== "selected") throw new Error("Evidence scope could not be narrowed"); if (api.larkWrites[1]?.turn_trigger !== "addressed") throw new Error("Trigger not restored"); if (context.errors.length) throw new Error(context.errors.join(" | ")); return {coverageEntries: await context.close(), note: "Steward group trigger opt-in/readback/revocation and typed non-execution feedback; desktop and narrow screen; scripted provider state only"}; diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index d4fb555e46..83fc2f673f 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -357,6 +357,31 @@ path; this does not add a visible settings control or launch workers. Prompt-onl adapters still have no interactive discovery tool. Live multi-worker adoption and original-conversation completion require separate qualification. +Local owner managers read all registered Goals by default. A bounded Turn index +is a summary, not an authorization list: on-demand Todo, delivery, Agent and +portfolio reads use the current audience scope. Portfolio pagination selects the +permitted registry page before collecting evidence, including Goals registered +after the initial Turn. Goal conversations remain confined to their selected +Goal; external conversations are rechecked before and after each read. + +New manager connections created in Settings → Lark default to **All registered +Goals, including new ones**. This applies to the connection's authorized audience, +including its group members. Existing connections retain their selected read +scope. Edit the existing connection's **Goal evidence access** to opt into the +live registered inventory or return to its selected scope. The UI reads back the +actual policy and preserves failures; changing this read setting does not grant +worker execution, publishing, context delivery or SSH access. + +The same policy is available through the existing trusted-local CLI. Omitted +`--read-goal-scope` preserves the selected-list behavior of existing scripts; +an empty selected list revokes the read grant. Old lists are not automatically +widened during installation. + +```sh +loopx manager-inbox configure-read-scope --channel-id manager.external.0123456789abcdef01234567 --read-goal-scope all_registered --execute +loopx manager-inbox configure-read-scope --channel-id manager.external.0123456789abcdef01234567 --read-goal-scope selected --read-goal-id project-a --execute +``` + Routine inspection excludes Goals explicitly stopped in Core, before status collection and detail reads. Coverage reports how many were skipped. Stale or unknown progress remains eligible. An explicit historical question can discover diff --git a/loopx/capabilities/manager_context/__init__.py b/loopx/capabilities/manager_context/__init__.py index 29f666fb74..9e394ab620 100644 --- a/loopx/capabilities/manager_context/__init__.py +++ b/loopx/capabilities/manager_context/__init__.py @@ -294,31 +294,35 @@ def produce(): ) -def evidence_goal_scope(runtime_root: Path, channel: str) -> list[str] | None: - """Audience-wide read grant; absent preserves the existing connection scope. +def evidence_scope_readback(runtime_root: Path, channel: str, *, registry_path: Path | None = None) -> dict: + """Observe this audience's typed read grant, separately from delivery. - Separate from sender-bound context delivery targets. An explicit empty grant - revokes access. Malformed policy fails closed, never widens to the registry. + Historical exact lists stay selected. An explicit all-registered grant + resolves the current registry on every observation; no saved inventory is + authority for newly registered work. Missing or corrupt sources fail closed. """ path = _root(runtime_root) / "policy.json" if not path.exists(): - return None + return {"goal_scope": "selected", "goal_ids": None} try: policy = _read(path) if policy.get("schema_version") != POLICY_SCHEMA: - return [] + return {"goal_scope": None, "goal_ids": []} source = policy.get("sources", {}).get(channel, {}) - if "evidence_goal_ids" not in source: - return None - ids = source["evidence_goal_ids"] - if not isinstance(ids, list) or any( - not isinstance(v, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", v) - for v in ids - ): - return [] - return sorted(set(ids)) - except (OSError, ValueError, TypeError, AttributeError): - return [] + available = None + if source.get("evidence_goal_scope") == "all_registered": + if registry_path is None: + return {"goal_scope": None, "goal_ids": []} + available = [g["id"] for g in load_project_registry(registry_path).get("goals", [])] + return effect_runtime_result("collaboration.source.evidence_goals", { + "source": source, "available_goal_ids": available, + }) + except (OSError, ValueError, KeyError, TypeError, AttributeError, RuntimeError): + return {"goal_scope": None, "goal_ids": []} + + +def evidence_goal_scope(runtime_root: Path, channel: str, *, registry_path: Path | None = None) -> list[str] | None: + return evidence_scope_readback(runtime_root, channel, registry_path=registry_path)["goal_ids"] def _require_external_channel(channel: str) -> None: @@ -329,27 +333,40 @@ def _require_external_channel(channel: str) -> None: def configure_evidence_scope(runtime_root: Path, registry_path: Path, *, channel: str, - goal_ids: list[str], execute: bool = False) -> dict: - """Local operator grants only selected Goal summaries to an exact audience.""" + goal_ids: list[str], goal_scope: str = "selected", execute: bool = False) -> dict: + """Configure read-only evidence through the existing audience policy owner.""" _require_external_channel(channel) registry = load_project_registry(registry_path) - available = {g.get("id") for g in registry.get("goals", []) if isinstance(g, dict)} - if any(g not in available for g in goal_ids): - raise ValueError("every read Goal must be registered") - ids = sorted(set(goal_ids)) + available = [g["id"] for g in registry.get("goals", [])] path = _root(runtime_root) / "policy.json" + + def update(apply: bool) -> dict: + policy = _read(path) if path.exists() else {"schema_version": POLICY_SCHEMA, "sources": {}} + if policy.get("schema_version") != POLICY_SCHEMA or not isinstance(policy.get("sources"), dict): + raise ValueError("invalid manager policy") + try: + planned = effect_runtime_result("collaboration.source.configure_evidence_scope", { + "source": policy["sources"].get(channel, {}), "goal_scope": goal_scope, + "goal_ids": goal_ids, "available_goal_ids": available, + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + if apply and planned["would_change"]: + policy["sources"][channel] = planned["source"] + _write(path, policy) + return planned + if execute: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with exclusive_file_lock(path.with_suffix(".lock")): - policy = _read(path) if path.exists() else {"schema_version": POLICY_SCHEMA, "sources": {}} - if policy.get("schema_version") != POLICY_SCHEMA: - raise ValueError("invalid manager policy") - policy.setdefault("sources", {}).setdefault(channel, {})["evidence_goal_ids"] = ids - _write(path, policy) - if evidence_goal_scope(runtime_root, channel) != ids: - raise ValueError("read scope verification failed") + result = update(True) + if update(False)["would_change"]: + raise ValueError("read scope verification failed") + else: + result = update(False) return {"ok": True, "executed": execute, "channel_id": channel, - "evidence_goal_ids": ids, "scope": "audience_goal_summaries", + "evidence_goal_ids": result["goal_ids"], "goal_scope": result["goal_scope"], + "scope": "audience_goal_summaries", "would_change": result["would_change"], "delegation_authority_changed": False} diff --git a/loopx/capabilities/manager_context/inspection.py b/loopx/capabilities/manager_context/inspection.py index 6caf205d1e..7cb9052f9a 100644 --- a/loopx/capabilities/manager_context/inspection.py +++ b/loopx/capabilities/manager_context/inspection.py @@ -314,6 +314,63 @@ def sources(self): from .ssh_evidence import sources return sources(self.runtime_root, self.channel_id, self.owner_scope, self.ssh_config_path) + def _registered_goals(self) -> list[dict[str, Any]]: + from ...history import decode_registry_snapshot + inventory = decode_registry_snapshot(self.registry_path, self.registry_path.read_bytes())["goals"] + if not isinstance(inventory, list) or any(not isinstance(g, dict) or not isinstance(g.get("id"), str) for g in inventory): + raise ValueError("Goal inventory is unreadable") + return inventory + + def _goal_scope(self) -> list[str] | None: + ids = (self.discovery_scope() if self.discovery_scope else + None if self.owner_scope and self.context.get("scope") == "owner_global" else + [r["goal_id"] for r in self.context.get("goals", [])]) + if ids is None: + if not (self.owner_scope and self.context.get("scope") == "owner_global"): + raise ValueError("current audience scope unavailable") + elif not isinstance(ids, list) or any(not isinstance(g, str) for g in ids): + raise ValueError("invalid audience scope") + return ids + + def _portfolio(self, ids, goal_id, include_stopped, offset, limit): + from ...control_plane.goals.activation import goal_activation_state + from ...chat_manager_context import manager_turn_context + try: + inventory = self._registered_goals() + permitted = sorted({g["id"] for g in inventory if ids is None or g["id"] in ids}) + if goal_id is not None: + permitted = [goal_id] if goal_id in permitted else [] + elif not include_stopped: + stopped = set() + for g in inventory: + if g["id"] not in permitted: + continue + try: + if goal_activation_state(g).value == "stopped": + stopped.add(g["id"]) + except ValueError: + pass # Unknown activation stays visible as a source gap. + permitted = [gid for gid in permitted if gid not in stopped] + selected = permitted[offset:offset + limit] + context = manager_turn_context( + self.registry_path, + {"channel_id": self.channel_id or ("manager" if self.owner_scope else "manager.external.inspection")}, + self.runtime_root, authorized_goal_ids=ids, requested_goal_ids=selected, + include_stopped=include_stopped or goal_id is not None, include_details=False, + ) if selected else {"goals": [], "coverage": {"complete": True}} + except (OSError, ValueError, KeyError, TypeError, RuntimeError): + return {"ok": False, "view": "portfolio", "error": "goal_inventory_unavailable"} + page = context.get("goals", []) + end = offset + len(selected) + return {"ok": True, "view": "portfolio", "goal_id": goal_id, "rows": page, + "offset": offset, "included": len(page), "matched": len(permitted), + "next_offset": end if end < len(permitted) else None, + "source": {"source": "goal_portfolio", "snapshot_id": context.get("snapshot_id"), + "coverage": context.get("coverage")}, + "unknown": bool(context.get("warnings")) or len(page) != len(selected), + "initial_snapshot_id": self.context.get("snapshot_id"), + "source_id": "local", "source_host": "local"} + def read(self, tool: str, arguments: Any) -> dict[str, Any]: if tool not in {TOOL_NAME, CONTEXT_TOOL_NAME} or not isinstance(arguments, dict): return {"ok": False, "error": "unsupported_read_tool"} @@ -359,19 +416,14 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: **({"runner": self.remote_runner} if self.remote_runner else {})) self.record(result) return result + try: + ids = self._goal_scope() + except ValueError: + return {"ok": False, "error": "authorization_changed"} + if goal_id is not None and ids is not None and goal_id not in ids: + return {"ok": False, "error": "goal_outside_available_scope"} if view in {"agents", "agent_route"}: - # The current audience scope is independent of bounded portfolio - # collection and the sender's narrower context-delivery grants. - ids = (self.discovery_scope() if self.discovery_scope else - None if self.owner_scope and self.context.get("scope") == "owner_global" else - [r["goal_id"] for r in self.context.get("goals", [])]) - if ids is None and not (self.owner_scope and self.context.get("scope") == "owner_global"): - return {"ok": False, "error": "authorization_changed"} - if ids is not None and (not isinstance(ids, list) or any(not isinstance(g, str) for g in ids)): - return {"ok": False, "error": "authorization_changed"} if goal_id is not None: - if ids is not None and goal_id not in ids: - return {"ok": False, "error": "goal_outside_available_scope"} ids = [goal_id] if view == "agent_route": from ...control_plane.collaboration.peer_host_route import resolve_peer_host_route @@ -392,24 +444,23 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: return {"ok": False, "error": "authorization_changed"} self.record(result) return result - goals = {r["goal_id"]: r for r in self.context.get("goals", [])} - if (goal_id is not None and goal_id not in goals) or ( - view not in {"portfolio", "handoffs"} and not goal_id - ): + if view not in {"portfolio", "handoffs"} and not goal_id: return {"ok": False, "error": "goal_outside_available_scope"} - if not self.scope_valid(): - return {"ok": False, "error": "authorization_changed"} + if view != "portfolio": + if ids is None: + try: + ids = [g["id"] for g in self._registered_goals()] + except (OSError, ValueError, KeyError, TypeError): + return {"ok": False, "error": "goal_inventory_unavailable"} + if goal_id is not None and goal_id not in ids: + return {"ok": False, "error": "goal_outside_available_scope"} + portfolio_unknown = False if view == "portfolio": - rows = list(goals.values()) if goal_id is None else [goals[goal_id]] - if goal_id is None and not include_stopped: - rows = [r for r in rows if r.get("activation_state") != "stopped"] - source = { - "source": "goal_portfolio", - "snapshot_id": self.context.get("snapshot_id"), - "coverage": self.context.get("coverage"), - } - page = rows[offset : offset + limit] - matched = len(rows) + result = self._portfolio(ids, goal_id, include_stopped, offset, limit) + if not result.get("ok"): + return result + page, source, matched = result["rows"], result["source"], result["matched"] + portfolio_unknown = result["unknown"] elif view == "handoffs": from .tracking import query @@ -417,7 +468,7 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: source = query( self.runtime_root, self.registry_path, - goal_ids=[goal_id] if goal_id else list(goals), + goal_ids=[goal_id] if goal_id else ids, owner_scope=self.owner_scope, channel_id=self.channel_id, request_id=arguments.get("request_id"), @@ -489,7 +540,7 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: "next_offset": end if isinstance(matched, int) and page and end < matched else None, - "unknown": matched is None + "unknown": matched is None or portfolio_unknown or ( view == "handoffs" and ( diff --git a/loopx/capabilities/manager_context/tracking.py b/loopx/capabilities/manager_context/tracking.py index d1f3c9e2c9..434091ab0c 100644 --- a/loopx/capabilities/manager_context/tracking.py +++ b/loopx/capabilities/manager_context/tracking.py @@ -108,7 +108,7 @@ def query( key=lambda row: (row.get("delivered_at") or "", row["request_id"]), reverse=True ) projected, todos_cache = [], {} - core_read_goals = set(evidence_goal_scope(root, channel_id) or []) if not owner_scope else set() + core_read_goals = set(evidence_goal_scope(root, channel_id, registry_path=registry_path) or []) if not owner_scope else set() for row in rows[offset : offset + limit]: read, read_error = _receipt(root, "reads", row) decision, decision_error = _receipt(root, "decisions", row) diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 9b3291380c..8448c789a2 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -535,25 +535,34 @@ def _lark_connections(self) -> None: if cli_bin is None: return registry = load_registry(self.server.registry_path) - self._send_json( - { - "ok": True, - "schema_version": "loopx_lark_goal_topic_connections_v0", - "connections": list_lark_connections( - registry=registry, - target_path=self._goal_channel_target_path(), - binding_paths=self._lark_binding_paths(registry), - runner=self._lark_runner(), - cli_bin=cli_bin, - runtime_health=( - self.server.lark_goal_topic_runtime.health_snapshot() - if getattr(self.server, "lark_goal_topic_runtime", None) - is not None - else None - ), - ), - } + connections = list_lark_connections( + registry=registry, + target_path=self._goal_channel_target_path(), + binding_paths=self._lark_binding_paths(registry), + runner=self._lark_runner(), + cli_bin=cli_bin, + runtime_health=( + self.server.lark_goal_topic_runtime.health_snapshot() + if getattr(self.server, "lark_goal_topic_runtime", None) is not None + else None + ), ) + from .capabilities.manager_context import evidence_scope_readback + + targets = read_goal_channel_targets(self._goal_channel_target_path()) + for row in connections: + if row.get("conversation_kind") != "manager": + continue + controller = getattr(self.server, "runtime_controller", None) + policy_root = getattr(controller, "coordination_runtime_root", None) or controller_runtime_root(controller) + target = goal_channel_target_for_name(targets, row["target_ref"]) or {} + audience = f"{(target.get('identity') or {}).get('sender_profile') or 'default'}\0{(target.get('channel') or {}).get('chat_id') or ''}" + readback = evidence_scope_readback( + policy_root, manager_channel(provider="lark", audience=audience), + registry_path=self.server.registry_path, + ) if policy_root is not None else {"goal_scope": None} + row["read_goal_scope"] = readback["goal_scope"] + self._send_json({"ok": True, "schema_version": "loopx_lark_goal_topic_connections_v0", "connections": connections}) def _lark_connect(self) -> None: cli_bin = self._require_lark_cli() @@ -569,6 +578,7 @@ def _lark_connect(self) -> None: "connection_id", "conversation_kind", "turn_trigger", + "read_goal_scope", "executor_endpoint_id", "chat_id", "chat_name", @@ -630,6 +640,11 @@ def _lark_connect(self) -> None: if conversation_kind != "manager" or app_refs_by_agent is not None: raise ValueError("turn_trigger is only supported for manager connections") conversation_trigger(body["turn_trigger"]) + if "read_goal_scope" in body and ( + conversation_kind != "manager" or not isinstance(body["read_goal_scope"], str) + or body["read_goal_scope"] not in {"all_registered", "selected"} + ): + raise ValueError("read_goal_scope requires a manager and all_registered or selected") # The machine owns its manager channel's executor, so the machine # setting -- not a stored connection field or a request field -- # decides which endpoint this connection runs on and which Session @@ -699,6 +714,20 @@ def provider_runner(args, cwd, timeout): "the manager requires an exact App and group audience" ) audience = f"{audience_app}\0{audience_chat}" + read_scope = body.get("read_goal_scope", "all_registered" if not stored else None) + read_scope_args = None + if read_scope is not None: + from .capabilities.manager_context import configure_evidence_scope, evidence_scope_readback + policy_root = getattr(self.server.runtime_controller, "coordination_runtime_root", None) or controller_runtime_root(self.server.runtime_controller) + if policy_root is None: + raise ValueError("manager runtime is unavailable") + read_channel = manager_channel(provider="lark", audience=audience) + previous = evidence_scope_readback(policy_root, read_channel, registry_path=self.server.registry_path) + if previous["goal_scope"] is None: + raise ValueError("manager read scope is unavailable; restore its policy before editing") + read_scope_args = {"channel": read_channel, "goal_scope": read_scope, + "goal_ids": previous["goal_ids"] if previous["goal_scope"] == "selected" and previous["goal_ids"] is not None else [goal_id]} + configure_evidence_scope(policy_root, self.server.registry_path, **read_scope_args) if body.get("execute") is True: goal = goal_from_registry(registry, goal_id) session, _ = open_manager_session( @@ -779,6 +808,24 @@ def provider_runner(args, cwd, timeout): else None, session_id=session_id, ) + if conversation_kind == "manager" and read_scope_args is not None and packet.get("ok") is True: + try: + packet.setdefault("details", {})["read_scope"] = configure_evidence_scope( + policy_root, self.server.registry_path, **read_scope_args, + execute=body.get("execute") is True, + ) + except (OSError, ValueError, RuntimeError): + # The provider binding has already committed. Preserve its + # receipt and refresh routing even when the read grant fails. + if body.get("execute") is True: + self._refresh_lark_goal_topic_runtime() + packet.update( + ok=False, + status="partially_connected" if body.get("execute") is True else "failed", + error="Connection saved, but its Goal read scope could not be verified. Retry saving the read scope.", + ) + self._send_json(packet, status=400) + return except CodexChatAgentError as exc: self._send_error( str(exc), status=400, gate=exc.gate, error_code=exc.error_code diff --git a/loopx/chat_manager_context.py b/loopx/chat_manager_context.py index 011dbd595b..227d3dd7a3 100644 --- a/loopx/chat_manager_context.py +++ b/loopx/chat_manager_context.py @@ -507,6 +507,8 @@ def manager_turn_context( runtime_root: Path, *, authorized_goal_ids: list[str] | None = None, + requested_goal_ids: list[str] | None = None, + include_stopped: bool = False, include_details: bool = True, evidence_window_days: int | None = None, remote_evidence: bool = False, @@ -569,12 +571,14 @@ def manager_turn_context( **window_kwargs, ), ) + if requested_goal_ids is not None and scope is not None and not set(requested_goal_ids).issubset(scope): + raise ValueError("requested Goals exceed the current evidence grant") portfolio = build_goal_portfolio( registry_path=registry_path, runtime_root_override=str(runtime_root), - goal_ids=scope, + goal_ids=requested_goal_ids if requested_goal_ids is not None else scope, limit=128, - include_stopped=False, + include_stopped=include_stopped, # The steward's answer order names the Goal's milestones and baseline, # so the lifecycle projection the status collector already derived # travels with the row instead of being reconstructed from todos. diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 6d3eb0033a..421f7e20b6 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1764,7 +1764,7 @@ def serve_chat( if isinstance(session.get("steward_context"), dict) else authorized_manager_goal_ids( build_lark_goal_topic_runtime_snapshot( registry_path=server.registry_path, runtime_root_override=server.runtime_root_override, - ), session, runtime_root=runtime_root, + ), session, runtime_root=runtime_root, registry_path=resolved_registry_path, )), codex_bin=codex_bin, claude_bin=claude_bin, diff --git a/loopx/cli_commands/manager_inbox.py b/loopx/cli_commands/manager_inbox.py index f62cf1f3e5..5196e9de16 100644 --- a/loopx/cli_commands/manager_inbox.py +++ b/loopx/cli_commands/manager_inbox.py @@ -64,6 +64,8 @@ def register_manager_inbox(subparsers, add_format): parser.add_argument("--channel-id") parser.add_argument("--ssh-host") parser.add_argument("--read-goal-id", action="append", default=[]) + parser.add_argument("--read-goal-scope", choices=("all_registered", "selected"), + help="Read scope only: all current/future registered Goals, or explicit --read-goal-id entries.") parser.add_argument("--local-delivery-scope", choices=("all_registered", "selected")) parser.add_argument("--sender-id", help="For configuring a new delivery source: its independently verified sender.") parser.add_argument("--execute", action="store_true") @@ -88,6 +90,8 @@ def register_manager_inbox(subparsers, add_format): def handle_manager_inbox(args, registry_path, runtime_root): try: + if getattr(args, "read_goal_scope", None) is not None and args.manager_inbox_action != "configure-read-scope": + raise ValueError("--read-goal-scope is only supported for configure-read-scope") if args.manager_inbox_action != "control" and any(getattr(args, key, None) is not None for key in ("control_operation", "control_file", "goal_instance_id")): raise ValueError("request control routing flags require the control action") if getattr(args, "request_revision", None) is not None and args.manager_inbox_action not in {"acknowledge", "report"}: @@ -157,6 +161,7 @@ def handle_manager_inbox(args, registry_path, runtime_root): registry_path, channel=args.channel_id or "", goal_ids=args.read_goal_id, + goal_scope=args.read_goal_scope or "selected", execute=args.execute, ) print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/loopx/control_plane/collaboration/source_grants.ts b/loopx/control_plane/collaboration/source_grants.ts index 9462bd369d..ee00700c1a 100644 --- a/loopx/control_plane/collaboration/source_grants.ts +++ b/loopx/control_plane/collaboration/source_grants.ts @@ -1,10 +1,37 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, requireJsonObject, requireNonEmptyString, requireStringArray } from "../runtime_decode.ts"; +import { normalizeStewardGoalScope } from "./conversation_scope.ts"; type Recipient = { goal_id: string; agent_id?: string }; type LocalDeliveryScope = "all_registered" | "selected"; +/** A live read grant is independent of delivery and of the bounded Turn index. + * Missing mode preserves historical exact lists and the connection's fallback. + * Only a trusted local configuration operation may select all registered Goals. + */ +export function resolveSourceEvidenceGoals(params: JsonObject): JsonObject { + const source = requireJsonObject(params.source, "source policy"); + const mode = Object.hasOwn(source, "evidence_goal_scope") ? source.evidence_goal_scope : "selected"; + if (mode !== "all_registered" && mode !== "selected") throw new EffectRuntimeRequestError("invalid evidence Goal scope"); + const selected = Object.hasOwn(source, "evidence_goal_ids") + ? normalizeStewardGoalScope(source.evidence_goal_ids) : null; + return {goal_scope: mode, goal_ids: mode === "all_registered" + ? normalizeStewardGoalScope(params.available_goal_ids) : selected}; +} + +export function configureSourceEvidenceScope(params: JsonObject): JsonObject { + const source = requireJsonObject(params.source, "source policy"); + const mode = params.goal_scope; + if (mode !== "all_registered" && mode !== "selected") throw new EffectRuntimeRequestError("invalid evidence Goal scope"); + const ids = normalizeStewardGoalScope(params.goal_ids); + const available = normalizeStewardGoalScope(params.available_goal_ids); + if (ids.some(id => !available.includes(id))) throw new EffectRuntimeRequestError("every read Goal must be registered"); + const next = {...source, evidence_goal_scope: mode, evidence_goal_ids: ids}; + return {source: next, goal_scope: mode, goal_ids: mode === "all_registered" ? available : ids, + would_change: JSON.stringify(next) !== JSON.stringify(source)}; +} + function recipients(value: unknown, label: string, exact: boolean): Recipient[] { if (!Array.isArray(value)) throw new EffectRuntimeRequestError(`${label} must be an array`); return value.map(row => { diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index df4991343e..52cae7cb08 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -624,6 +624,8 @@ export function createEffectRuntimeHandlers( ["collaboration.source.execution_bindings", lazyHandler(() => import("./collaboration/source_grants.ts"), ({sourceExecutionBindings}) => sourceExecutionBindings)], ["collaboration.source.configure_recipient", lazyHandler(() => import("./collaboration/source_grants.ts"), ({configureSourceRecipient}) => configureSourceRecipient)], ["collaboration.source.configure_scope", lazyHandler(() => import("./collaboration/source_grants.ts"), ({configureSourceScope}) => configureSourceScope)], + ["collaboration.source.evidence_goals", lazyHandler(() => import("./collaboration/source_grants.ts"), ({resolveSourceEvidenceGoals}) => resolveSourceEvidenceGoals)], + ["collaboration.source.configure_evidence_scope", lazyHandler(() => import("./collaboration/source_grants.ts"), ({configureSourceEvidenceScope}) => configureSourceEvidenceScope)], ["collaboration.conversation.reply_context", lazyHandler(() => import("./collaboration/conversation_reply_context.ts"), ({projectConversationReplyContext}) => projectConversationReplyContext)], ["chat.turn.accept", lazyHandler(() => import("./turn_driver/chat_turn_acceptance.ts"), ({planChatTurnAcceptance}) => planChatTurnAcceptance)], ["chat.turn.execution_allowed", lazyHandler(() => import("./turn_driver/chat_turn_acceptance.ts"), ({mayContinueChatTurn}) => mayContinueChatTurn)], diff --git a/loopx/extensions/lark/manager_returns.py b/loopx/extensions/lark/manager_returns.py index 67869cc7eb..dadf3ed1a6 100644 --- a/loopx/extensions/lark/manager_returns.py +++ b/loopx/extensions/lark/manager_returns.py @@ -27,7 +27,7 @@ def _resolve_return( snapshot = snapshot_provider() # Validate live binding/session independently of the worker's target Goal. # Delegation grants permit only this request's audience-ready reply. - if not authorized_manager_goal_ids(snapshot, session, runtime_root=root): + if not authorized_manager_goal_ids(snapshot, session, runtime_root=root, registry_path=registry): raise ReturnResolutionBlocked( "return_authorization_unavailable", "manager connection no longer authorized", diff --git a/loopx/extensions/lark/manager_routing.py b/loopx/extensions/lark/manager_routing.py index fc4a91b9e1..bfdebc9f16 100644 --- a/loopx/extensions/lark/manager_routing.py +++ b/loopx/extensions/lark/manager_routing.py @@ -234,7 +234,7 @@ def _valid_manager_binding(goal_id, binding, routing) -> bool: def authorized_manager_goal_ids( - snapshot: Mapping[str, Any], session: Mapping[str, Any], *, runtime_root: Path | None = None + snapshot: Mapping[str, Any], session: Mapping[str, Any], *, runtime_root: Path | None = None, registry_path: Path | None = None ) -> list[str]: """Resolve current external read authority; a session's old Goal is not a grant.""" candidates = [] @@ -277,7 +277,7 @@ def authorized_manager_goal_ids( return [] if runtime_root is not None: from ...capabilities.manager_context import evidence_goal_scope - grant = evidence_goal_scope(runtime_root, str(session.get("channel_id") or "")) + grant = evidence_goal_scope(runtime_root, str(session.get("channel_id") or ""), registry_path=registry_path) if grant is not None: return grant return [goal_id] diff --git a/tests/control_plane_ts/source_grants.test.ts b/tests/control_plane_ts/source_grants.test.ts index 2f893b29fc..9f19a50736 100644 --- a/tests/control_plane_ts/source_grants.test.ts +++ b/tests/control_plane_ts/source_grants.test.ts @@ -135,3 +135,17 @@ test("an Agent revocation while its Goal is disabled survives Goal restoration", { targets: policy === source ? [peer, worker] : [other, peer, worker] }); } }); + +test("evidence reads follow current registered Goals only after explicit all-registered configuration", async () => { + const {resolveSourceEvidenceGoals, configureSourceEvidenceScope} = await import("../../loopx/control_plane/collaboration/source_grants.ts"); + assert.deepEqual(resolveSourceEvidenceGoals({source, available_goal_ids: ["research", "later"]}), {goal_scope: "selected", goal_ids: null}); + const selected = {...source, evidence_goal_ids: ["research"]}; + assert.deepEqual(resolveSourceEvidenceGoals({source: selected, available_goal_ids: ["research", "later"]}).goal_ids, ["research"]); + const next = configureSourceEvidenceScope({source: selected, goal_scope: "all_registered", goal_ids: ["research"], available_goal_ids: ["research"]}); + assert.deepEqual(resolveSourceEvidenceGoals({source: next.source, available_goal_ids: ["research", "later"]}).goal_ids, ["later", "research"]); + assert.deepEqual((next.source as typeof selected).targets, selected.targets); + assert.deepEqual(resolveSourceEvidenceGoals({source: {...selected, evidence_goal_ids: []}, available_goal_ids: ["research"]}).goal_ids, []); + for (const invalid of [{evidence_goal_scope: "unknown"}, {evidence_goal_ids: ["*"]}, {evidence_goal_ids: null}, {evidence_goal_scope: "all_registered"}]) { + assert.throws(() => resolveSourceEvidenceGoals({source: invalid})); + } +}); diff --git a/tests/test_chat_lark_api_contract.py b/tests/test_chat_lark_api_contract.py index 5b1e6f0756..20b6404fae 100644 --- a/tests/test_chat_lark_api_contract.py +++ b/tests/test_chat_lark_api_contract.py @@ -763,15 +763,28 @@ def rebind(**_kwargs: Any): assert reconciled["connector"]["session_ref"] == "new-session" -@pytest.mark.parametrize("execute", [False, True]) +@pytest.mark.parametrize("execute, scope_failure", [(False, False), (True, False), (True, True)]) def test_manager_connection_opens_audience_session_only_on_execute( - monkeypatch: Any, tmp_path: Path, execute: bool + monkeypatch: Any, tmp_path: Path, execute: bool, scope_failure: bool ) -> None: import loopx.chat_lark_api as api + import loopx.capabilities.manager_context as manager_context from loopx.chat_manager import manager_channel + registry_path = tmp_path / "registry.json" + registry_path.write_text(json.dumps({"goals": [{"id": "goal-alpha", "repo": str(tmp_path)}]})) calls: list[dict[str, Any]] = [] opened: list[dict[str, Any]] = [] + responses: list[tuple[dict[str, Any], int]] = [] + refreshed: list[bool] = [] + configure = manager_context.configure_evidence_scope + + def configure_scope(*args, **kwargs): + if scope_failure and kwargs.get("execute"): + raise OSError("synthetic policy write failure") + return configure(*args, **kwargs) + + monkeypatch.setattr(manager_context, "configure_evidence_scope", configure_scope) monkeypatch.setattr( api, "connect_lark_goal_topic", @@ -781,14 +794,16 @@ def test_manager_connection_opens_audience_session_only_on_execute( class Handler(LarkChatRequestMixin): path = "/api/chat/lark/connections" server = SimpleNamespace( + registry_path=registry_path, chat_store=SimpleNamespace(latest_session=lambda **kwargs: None), runtime_controller=SimpleNamespace( + coordination_runtime_root=tmp_path / "runtime", open_session=lambda **kwargs: ( opened.append(kwargs) or {"session_id": "manager-session"}, False, ) ), - lark_goal_topic_runtime=SimpleNamespace(refresh=lambda: None), + lark_goal_topic_runtime=SimpleNamespace(refresh=lambda: refreshed.append(True)), ) def _require_lark_cli(self): @@ -817,12 +832,18 @@ def _lark_runner(self): return lambda *_args: {"returncode": 0, "stdout": "{}", "stderr": ""} def _send_json(self, payload, *, status=200): - assert payload["ok"] + responses.append((payload, status)) def _send_error(self, message, **kwargs): raise AssertionError(message) Handler()._lark_connect() + assert responses[0][0]["ok"] is not scope_failure + assert responses[0][1] == (400 if scope_failure else 200) + if scope_failure: + assert responses[0][0]["status"] == "partially_connected" + assert "read scope" in responses[0][0]["error"] + assert len(refreshed) == int(execute) assert calls[0]["conversation_kind"] == "manager" assert calls[0]["turn_trigger"] == "human_messages" assert calls[0]["ingress_mode"] == "session_queue" @@ -835,3 +856,7 @@ def _send_error(self, message, **kwargs): provider="lark", audience="mew\0oc_public_fixture" ) assert opened[0]["channel_id"] != "manager" + + from loopx.capabilities.manager_context import evidence_goal_scope + observed = evidence_goal_scope(tmp_path / "runtime", manager_channel(provider="lark", audience="mew\0oc_public_fixture"), registry_path=registry_path) + assert observed == (["goal-alpha"] if execute and not scope_failure else None) diff --git a/tests/test_chat_manager_context.py b/tests/test_chat_manager_context.py index 0a58c25dad..3bda5f6df7 100644 --- a/tests/test_chat_manager_context.py +++ b/tests/test_chat_manager_context.py @@ -100,6 +100,7 @@ def test_current_agent_work_reaches_prompt_without_extra_detail_reads( ): from loopx.capabilities.manager_context.inspection import ManagerInspection, TOOL_NAME, manager_index + (tmp_path / "registry.json").write_text(json.dumps({"goals": [{"id": "alpha"}]})) todos = [{"todo_id": f"todo_{i}", "title": f"Implement task {i}", "status": "in-progress", "priority": "P0", "claimed_by": "worker", "readiness": "runnable", "raw_receipt": "excluded"} for i in range(5)] diff --git a/tests/test_chat_manager_inspection.py b/tests/test_chat_manager_inspection.py index 5ee363719d..111bcdbf3e 100644 --- a/tests/test_chat_manager_inspection.py +++ b/tests/test_chat_manager_inspection.py @@ -24,6 +24,7 @@ def inspector(tmp_path, scope=lambda: True): records = [] + (tmp_path / "registry.json").write_text(json.dumps({"goals": [{"id": "alpha"}]})) tool = ManagerInspection( context={"snapshot_id": "fixture", "goals": [{"goal_id": "alpha"}]}, registry_path=tmp_path / "registry.json", @@ -291,7 +292,7 @@ def test_attention_reference_recovers_canonical_long_todo(tmp_path, monkeypatch, assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == before -def test_unavailable_is_unknown_and_large_portfolio_is_disclosed(tmp_path): +def test_unavailable_is_unknown_and_large_portfolio_is_disclosed(tmp_path, monkeypatch): tool, records = inspector(tmp_path) result = tool.read(TOOL_NAME, {"view": "deliveries", "goal_id": "alpha"}) assert result["unknown"] and result["matched"] is None @@ -299,6 +300,10 @@ def test_unavailable_is_unknown_and_large_portfolio_is_disclosed(tmp_path): tool.context["goals"][0]["current_todos"] = {"body": "x" * 40000} index = manager_index(tool.context) assert len(json.dumps(index)) < 2000 + # The detailed portfolio is now a fresh read, not the Turn's compact index. + monkeypatch.setattr("loopx.chat_manager_context.manager_turn_context", lambda *a, **kw: { + "goals": tool.context["goals"], "coverage": {"complete": True}, + }) result = tool.read(TOOL_NAME, {"view": "portfolio"}) assert result["oversized_rows"] == [0] assert len(json.dumps(result)) < 2000 @@ -459,6 +464,9 @@ def test_manager_runtime_installs_tool_and_records_real_subprocess_read( from loopx.chat_store import ChatSessionStore import loopx.chat_manager_context as context + (tmp_path / "registry.json").write_text(json.dumps({"goals": [ + {"id": "alpha", "registered_agents": ["review-worker"]} + ]})) fake = tmp_path / "fake-codex" fake.write_text("""#!/usr/bin/env python3 import json, sys @@ -498,18 +506,12 @@ def test_manager_runtime_installs_tool_and_records_real_subprocess_read( "'view':'todos','goal_id':'alpha'", "'view':'agents','query':'review'").replace( "evidence['rows'][0]['title'] == 'Check the sample result'", "evidence['rows'][0]['agent_id'] == 'review-worker' and evidence['rows'][0]['execution_readiness'] == 'not_checked'")) - (tmp_path / "registry.json").write_text(json.dumps({"goals": [ - {"id": "alpha", "registered_agents": ["review-worker"]} - ]})) if read_view == "agent_route": fake.write_text(fake.read_text().replace( "'view':'todos','goal_id':'alpha'", "'view':'agent_route','goal_id':'alpha','agent_id':'review-worker'").replace( "evidence['rows'][0]['title'] == 'Check the sample result'", "evidence['status'] == 'unavailable' and evidence['reason'] == 'no_binding' and evidence['host_delivery'] == 'not_attempted'")) - (tmp_path / "registry.json").write_text(json.dumps({"goals": [ - {"id": "alpha", "registered_agents": ["review-worker"]} - ]})) if read_view == "todos_exact": fake.write_text(fake.read_text().replace( "'view':'todos','goal_id':'alpha'", "'view':'todos','goal_id':'alpha','todo_id':'todo_sample'").replace( @@ -597,6 +599,10 @@ def test_stopped_goals_are_opt_in_but_stale_active_remains_visible(tmp_path): index = manager_index(tool.context) assert [r['goal_id'] for r in index['goals']] == ['alpha', 'unknown'] assert index['stopped_goals_excluded'] == 1 + tool.registry_path.write_text(json.dumps({"goals": [ + {"id": row["goal_id"], "activation_state": row["activation_state"]} + for row in tool.context["goals"] + ]})) current = tool.read(TOOL_NAME, {'view': 'portfolio'}) assert current['matched'] == 2 history = tool.read(TOOL_NAME, {'view': 'portfolio', 'include_stopped': True}) diff --git a/tests/test_manager_evidence_scope.py b/tests/test_manager_evidence_scope.py new file mode 100644 index 0000000000..e1d88af72b --- /dev/null +++ b/tests/test_manager_evidence_scope.py @@ -0,0 +1,107 @@ +"""A manager's live evidence grant is not its initial bounded portfolio.""" +import json + +import pytest + +from loopx.capabilities.manager_context import ( + POLICY_SCHEMA, _root, configure_evidence_scope, evidence_goal_scope, +) +from loopx.capabilities.manager_context.inspection import ManagerInspection, TOOL_NAME +from tests.control_plane.canonical_authority_fixture import ( + isolate_sqlite_runtime, promoted_create_fixture, +) + +CHANNEL = "manager.external." + "a" * 24 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_live_scope_reads_a_real_provider_absent_from_the_turn_index(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, root, state = promoted_create_fixture(tmp_path, provider=provider) + policy = _root(root) / "policy.json" + policy.parent.mkdir(parents=True, exist_ok=True) + grants = {"sender_ids": ["owner"], "targets": [], "blocked_targets": [{"goal_id": "goal-a"}]} + policy.write_text(json.dumps({"schema_version": POLICY_SCHEMA, "sources": {CHANNEL: grants}})) + configure_evidence_scope(root, registry, channel=CHANNEL, goal_ids=[], goal_scope="all_registered", execute=True) + reader = ManagerInspection(context={"scope": "external_goal_scope", "goals": []}, + registry_path=registry, runtime_root=root, owner_scope=False, channel_id=CHANNEL, + scope_valid=lambda: True, record=lambda _: None, + discovery_scope=lambda: evidence_goal_scope(root, CHANNEL, registry_path=registry)) + before = state.read_bytes() + result = reader.read(TOOL_NAME, {"view": "todos", "goal_id": "goal-a"}) + assert result["ok"] and result["source"]["status"] == "read" + assert result["source"]["coverage"]["active"] == 0 + assert state.read_bytes() == before + saved = json.loads(policy.read_text())["sources"][CHANNEL] + assert all(saved[key] == value for key, value in grants.items()) + # Registration after configuration changes the next read, without rewriting policy. + data = json.loads(registry.read_text()) + data["goals"].append({"id": "later", "repo": str(tmp_path), "state_file": str(state)}) + registry.write_text(json.dumps(data)) + assert evidence_goal_scope(root, CHANNEL, registry_path=registry) == ["goal-a", "later"] + assert reader.read(TOOL_NAME, {"view": "agents", "goal_id": "later"})["ok"] + assert json.loads(policy.read_text())["sources"][CHANNEL] == saved + # Explicit empty selected scope revokes the broader read without changing delivery. + configure_evidence_scope(root, registry, channel=CHANNEL, goal_ids=[], execute=True) + assert reader.read(TOOL_NAME, {"view": "todos", "goal_id": "goal-a"})["error"] == "goal_outside_available_scope" + + +def test_portfolio_pages_current_permitted_inventory_before_collection(tmp_path, monkeypatch): + import loopx.chat_manager_context as context + registry = tmp_path / "registry.json" + ids = [f"goal-{i:03}" for i in range(145)] + registry.write_text(json.dumps({"goals": [{"id": gid} for gid in ids]})) + calls = [] + + def collect(_registry, _session, _root, **kwargs): + selected = kwargs["requested_goal_ids"] + calls.append(selected) + assert len(selected) <= 12 + return {"goals": [{"goal_id": gid} for gid in selected], "coverage": {"complete": True}} + + monkeypatch.setattr(context, "manager_turn_context", collect) + reader = ManagerInspection(context={"scope": "owner_global", "goals": []}, + registry_path=registry, runtime_root=tmp_path, owner_scope=True, + scope_valid=lambda: True, record=lambda _: None) + found, offset = [], 0 + while offset is not None: + page = reader.read(TOOL_NAME, {"view": "portfolio", "offset": offset, "limit": 12}) + found.extend(row["goal_id"] for row in page["rows"]) + offset = page["next_offset"] + assert found == ids + assert calls[-1] == ids[-1:] + reader.owner_scope = False + reader.context["scope"] = "external_goal_scope" + reader.discovery_scope = lambda: [ids[-1]] + assert reader.read(TOOL_NAME, {"view": "portfolio"})["rows"] == [{"goal_id": ids[-1]}] + assert calls[-1] == [ids[-1]] + + +def test_scope_change_during_a_detail_read_discards_the_result(tmp_path, monkeypatch): + from loopx.capabilities.manager_context import inspection + calls, records = [], [] + active = [True] + + def details(*a, **kw): + calls.append(a[2]) + active[0] = False + return {"status": "read", "todos": [], "coverage": {"active": 0}} + + monkeypatch.setattr(inspection, "read_manager_goal_details", details) + reader = ManagerInspection(context={"scope": "external_goal_scope", "goals": []}, + registry_path=tmp_path / "registry.json", runtime_root=tmp_path, owner_scope=False, + discovery_scope=lambda: ["allowed"], scope_valid=lambda: active[0], record=records.append) + assert reader.read(TOOL_NAME, {"view": "todos", "goal_id": "allowed"}) == {"ok": False, "error": "authorization_changed"} + assert calls == ["allowed"] and records == [] + + +def test_unreadable_inventory_is_not_an_empty_authorized_portfolio(tmp_path): + registry = tmp_path / "registry.json" + reader = ManagerInspection(context={"scope": "owner_global", "goals": []}, + registry_path=registry, runtime_root=tmp_path, owner_scope=True, + scope_valid=lambda: True, record=lambda _: None) + for content in [None, "{", '{"goals": [null]}']: + if content is not None: + registry.write_text(content) + result = reader.read(TOOL_NAME, {"view": "portfolio"}) + assert result["ok"] is False and result["error"] == "goal_inventory_unavailable"