From 24e787c10930545284af83b36ae8b0924b6a5766 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:16:49 +0800 Subject: [PATCH 1/2] fix(manager-runtime): honor verified owner-private host grants Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/manager_runtime/__init__.py | 2 + .../manager_runtime/machine_profile.py | 34 +++- .../native_chat/conversation_bindings.py | 7 + loopx/chat_coordination.py | 3 +- loopx/chat_runtime.py | 16 +- .../collaboration/conversation_binding.ts | 7 +- .../project_registry_io_manifest_v1.json | 2 +- .../test_manager_runtime_profile.py | 10 ++ .../conversation_binding.test.ts | 17 ++ tests/test_native_steward_runtime.py | 166 ++++++++++++++++++ 10 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 tests/test_native_steward_runtime.py diff --git a/loopx/capabilities/manager_runtime/__init__.py b/loopx/capabilities/manager_runtime/__init__.py index 6a354a8fcc..9e536411c5 100644 --- a/loopx/capabilities/manager_runtime/__init__.py +++ b/loopx/capabilities/manager_runtime/__init__.py @@ -3,6 +3,7 @@ from .machine_profile import ( MANAGER_RUNTIME_PROFILE_SCHEMA, effective_manager_runtime_profile, + load_bound_manager_runtime_profile, load_effective_manager_runtime_profile, manager_runtime_capability_projection, manager_runtime_machine_configuration_namespace, @@ -13,6 +14,7 @@ __all__ = [ "MANAGER_RUNTIME_PROFILE_SCHEMA", "effective_manager_runtime_profile", + "load_bound_manager_runtime_profile", "load_effective_manager_runtime_profile", "manager_runtime_capability_projection", "manager_runtime_machine_configuration_namespace", diff --git a/loopx/capabilities/manager_runtime/machine_profile.py b/loopx/capabilities/manager_runtime/machine_profile.py index a00ad9e9cf..4509ca64c9 100644 --- a/loopx/capabilities/manager_runtime/machine_profile.py +++ b/loopx/capabilities/manager_runtime/machine_profile.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from ..machine_configuration.contract import ( MACHINE_CONFIGURATION_SCHEMA, @@ -10,6 +10,9 @@ machine_configuration_revision, ) +if TYPE_CHECKING: + from ..native_chat.conversation_bindings import ChatConversationBindings + MANAGER_RUNTIME_PROFILE_SCHEMA = "manager_runtime_profile_v0" MANAGER_RUNTIME_EFFECTIVE_SCHEMA = "manager_runtime_effective_profile_v0" RESTRICTED_PROFILE = "restricted" @@ -138,12 +141,14 @@ def load_effective_manager_runtime_profile( runtime_root: Path, *, channel_id: str = "manager", + owner_manager_audience: bool = False, ) -> dict[str, Any]: """Read the selected profile for one audience-bound manager channel. - The machine choice is sufficient for the private owner conversation. An - external audience is a different trust boundary and cannot inherit broad - host-tool access until an existing audience/resource grant can be checked. + The machine choice is sufficient for the local owner conversation. Native + bindings may also supply a freshly checked Core owner-audience proof. This + proof alone grants no host tools; the persistent machine grant is required. + Other external audiences always remain restricted. """ from ..machine_configuration.store import read_stored_machine_configuration @@ -159,6 +164,7 @@ def load_effective_manager_runtime_profile( } if ( channel_id != "manager" + and owner_manager_audience is not True and effective["runtime_profile"] == TRUSTED_OWNER_PROFILE ): return { @@ -183,6 +189,26 @@ def load_effective_manager_runtime_profile( } +def load_bound_manager_runtime_profile( + runtime_root: Path, + *, + channel_id: str, + bindings: ChatConversationBindings | None, + steward_context: dict[str, Any] | None, +) -> dict[str, Any]: + """Compose fresh Core audience authority with the machine-owned host grant.""" + + owner_manager_audience = False + if steward_context is not None: + if bindings is None: + raise ValueError("bound manager authority is unavailable") + owner_manager_audience = bindings.manager_runtime_owner(steward_context, channel_id) + return load_effective_manager_runtime_profile( + runtime_root, channel_id=channel_id, + owner_manager_audience=owner_manager_audience, + ) + + def manager_runtime_session_fields(profile: Mapping[str, Any]) -> dict[str, Any]: """Project the effective profile fields persisted with a manager session.""" diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index a58b71ca26..be8eb06521 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -163,6 +163,13 @@ def session_context(self, saved: dict[str, Any]) -> dict[str, Any]: return self.resolve(binding_id=saved["binding_id"], source_ref=saved["source_ref"], sender_ref=saved["operator_ref"], private_human_message=True, session_context=saved) + def manager_runtime_owner(self, saved: dict[str, Any], channel_id: str) -> bool: + """Fresh Core audience proof; saved Session text is never authority.""" + selected = self.session_context(saved) + if selected["channel_id"] != channel_id: + raise ValueError("bound manager audience changed") + return selected["owner_manager_audience"] is True + def steward_scope(self, session: dict[str, Any]) -> list[str] | None: saved = session.get("steward_context") if not isinstance(saved, dict): diff --git a/loopx/chat_coordination.py b/loopx/chat_coordination.py index a397870e98..ab95975675 100644 --- a/loopx/chat_coordination.py +++ b/loopx/chat_coordination.py @@ -106,7 +106,8 @@ def prepare_turn_context(controller, adapter, session, turn_id, event_sink, *, s if controller.adapters.get(session_id) is adapter: controller.adapters.pop(session_id, None) manager_runtime = controller.manager_runtime_profile( - str(session.get("channel_id") or "manager") + str(session.get("channel_id") or "manager"), + steward_context=session.get("steward_context"), ) adapter = controller._start_adapter( agent_id=str(session["agent_id"]), diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index b47ce5ed91..53beed07c4 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -22,7 +22,7 @@ from .capabilities.native_chat import codex_context, project_context as project_context_policy from .control_plane.collaboration import conversation_scope from .capabilities.manager_runtime import ( - load_effective_manager_runtime_profile, manager_runtime_session_fields, + load_bound_manager_runtime_profile, manager_runtime_session_fields, ) from .capabilities.manager_context.team_plan import ( TeamPlanProjector, @@ -341,11 +341,12 @@ def __init__( self.team_plan_projector: TeamPlanProjector | None = None def manager_runtime_profile( - self, channel_id: str = "manager" + self, channel_id: str = "manager", *, + steward_context: dict[str, Any] | None = None, ) -> dict[str, Any]: - return load_effective_manager_runtime_profile( - self.store.root.parent, - channel_id=channel_id, + return load_bound_manager_runtime_profile( + self.store.root.parent, channel_id=channel_id, + bindings=self.project_contexts.conversation_bindings, steward_context=steward_context, ) def steward_executor_defaults(self) -> dict[str, Any]: @@ -606,7 +607,7 @@ def open_session( if project_context is not None: project_context_policy.validate_project_executor_scope(agent_id, project_context, self.capabilities, capability) manager_runtime = ( - self.manager_runtime_profile(selected_channel) + self.manager_runtime_profile(selected_channel, steward_context=steward_context) if is_manager_channel(selected_channel) else None ) @@ -755,7 +756,8 @@ def _ensure_adapter_locked( project_context_policy.validate_project_executor_scope(str(session["agent_id"]), session["project_context"], self.capabilities) work_dir, objective = context["project"], context["objective"] manager_runtime = ( - self.manager_runtime_profile(str(session.get("channel_id") or "manager")) + self.manager_runtime_profile(str(session.get("channel_id") or "manager"), + steward_context=session.get("steward_context")) if is_manager_channel(session.get("channel_id")) else None ) diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index c6f5339f61..c4df136d7d 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -255,7 +255,12 @@ export function resolveBoundConversation(params: JsonObject): JsonObject { : JSON.stringify(saved) === JSON.stringify(context); if (!matches) throw new EffectRuntimeRequestError("bound Session context changed"); } - return {binding: row, context, channel_id: row.context_kind === "steward" + // This is an audience proof, not a host-tool grant. Only the independently + // verified owner in this private source may inherit an explicit machine + // owner grant. Portfolio scope and project write grants do not imply it. + return {binding: row, context, owner_manager_audience: row.context_kind === "steward" + && projects[0].filesystem_scope !== "workspace_only", + channel_id: row.context_kind === "steward" ? `manager.external.native.${id}.${source}` : `project.external.${id}.${source}`}; } diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 3c587ac987..420fc348cd 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -287,7 +287,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.agent_observation::codec_read:load_registry#1", - "line": 202, + "line": 209, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/capabilities/test_manager_runtime_profile.py b/tests/capabilities/test_manager_runtime_profile.py index 2689943bee..b15d916c67 100644 --- a/tests/capabilities/test_manager_runtime_profile.py +++ b/tests/capabilities/test_manager_runtime_profile.py @@ -100,6 +100,16 @@ def test_external_audience_does_not_inherit_owner_host_tools(tmp_path: Path) -> assert effective["source"] == "external_audience_boundary" +@pytest.mark.parametrize("profile", [None, "restricted", "trusted_owner"]) +def test_owner_audience_proof_does_not_replace_the_machine_grant(tmp_path: Path, profile) -> None: + if profile is not None: + _apply(tmp_path, profile) + effective = load_effective_manager_runtime_profile(tmp_path, + channel_id="manager.external.native.fixture", owner_manager_audience=True) + assert effective["runtime_profile"] == (profile or "restricted") + assert effective["standing_grant"] == ("machine_configuration" if profile == "trusted_owner" else "none") + + def test_unrelated_machine_configuration_does_not_change_manager_revision() -> None: before = effective_manager_runtime_profile( { diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index 176e9bb88c..402e0aed7b 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -16,6 +16,23 @@ const current = {schema_version: "loopx_chat_conversation_bindings_v0", revision const request = {current, expected_revision: 0, operation: "configure", binding: row, observation, available_projects: [project]}; +test("only a current verified private steward binding proves the machine owner audience", () => { + const steward = {...row, context_kind: "steward", grant: "portfolio_read", goal_ids: []}; + const next = planConversationBinding({...request, binding: steward}).state; + const use = {current: next, binding_id: row.binding_id, source_ref: "e".repeat(24), + sender_ref: row.operator_ref, private_human_message: true, observation, available_projects: [project]}; + assert.equal(resolveBoundConversation(use).owner_manager_audience, true); + assert.equal(resolveBoundConversation({...use, + available_projects: [{...project, filesystem_scope: "workspace_only"}]}).owner_manager_audience, false); + const projectState = planConversationBinding(request).state; + assert.equal(resolveBoundConversation({...use, current: projectState}).owner_manager_audience, false); + for (const denied of [{private_human_message: false}, {sender_ref: "f".repeat(24)}, + {observation: {...observation, provider_ref: "f".repeat(24)}}, + {observation: {...observation, verified: false}}, {available_projects: []}, {current}]) { + assert.throws(() => resolveBoundConversation({...use, ...denied})); + } +}); + test("images use the managed conversation and never disappear into commands or an attached host", () => { const request = {request_ref: "e".repeat(24), command: null}; assert.equal(planBoundConversationRequest({request, current_session: null, attachment_count: 1}).operation, "admit_turn"); diff --git a/tests/test_native_steward_runtime.py b/tests/test_native_steward_runtime.py new file mode 100644 index 0000000000..76d0e03a05 --- /dev/null +++ b/tests/test_native_steward_runtime.py @@ -0,0 +1,166 @@ +"""Private owner audience and explicit host grant meet at the native Session. + +The real Core, configuration store and app-server RPC path are exercised with +synthetic provider/model fixtures, not live credentials or model acceptance. +""" +import json + +import pytest + +from test_native_steward_private import steward, finish # noqa: F401 +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.machine_configuration.store import configure_machine_configuration +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore + + +def apply_profile(root, profile): + arguments = {"runtime_root": root, "registry": build_builtin_machine_configuration_registry(), + "configuration": {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "manager_runtime": {"schema_version": "manager_runtime_profile_v0", "runtime_profile": profile}}}} + preview = configure_machine_configuration(**arguments, execute=False) + result = configure_machine_configuration(**arguments, execute=True, + expected_plan_revision=preview["plan_revision"]) + assert result["readback_verified"] + + +def test_private_steward_uses_machine_grant_and_continues_without_widening_other_app(steward): # noqa: F811 + store, runtime, provider, transport, binding, capture, _ = steward + + def admit(name, text): + assert transport.admit("steward-app", provider.event("steward-app", name, text))["status"] == "durably_accepted" + row = next(item for item in transport.core.pending() if item["message"] == text) + assert finish(runtime, row)["status"] == "completed" + return store.load_session(row["session_id"]) + + first = admit("default", "Remember the owner's initial request.") + assert first["manager_runtime_profile"] == "restricted" + apply_profile(store.root.parent, "trusted_owner") + trusted = admit("authorized", "Use the available tools for this authorized request.") + assert trusted["session_id"] == first["session_id"] + readback = store.public_session(trusted)["manager_runtime"] + assert readback["runtime_profile"] == "trusted_owner" + assert readback["sandbox"] == "danger-full-access" + assert readback["standing_grant"] == "machine_configuration" + assert {"shell", "filesystem", "web"} <= set(readback["tool_classes"]) + followup = admit("continued", "Continue the same conversation.") + assert followup["session_id"] == trusted["session_id"] + assert followup["upstream_thread_id"] == trusted["upstream_thread_id"] + calls = [json.loads(line) for line in capture.read_text().splitlines()] + starts = [call["params"] for call in calls if call.get("method") == "thread/start"] + assert [call["sandbox"] for call in starts] == ["read-only", "danger-full-access"] + prompts = [call["params"]["input"][0]["text"] for call in calls if call.get("method") == "turn/start"] + assert "normal tools and skills" in prompts[-1] + assert "Remember the owner's initial request." in prompts[1] + assert "Do not inspect arbitrary repositories" not in prompts[-1] + transport.reconcile() + assert all(item[0] == "steward-app" for item in provider.writes) + + transport.admit("notes-app", provider.event("notes-app", "separate", "An independent project request.")) + notes = next(item for item in transport.core.pending() if item["message"] == "An independent project request.") + assert finish(runtime, notes)["status"] == "completed" + assistant = store.load_session(notes["session_id"]) + assert assistant["session_id"] != trusted["session_id"] + assert assistant["project_context"]["grant"] == "workspace_write" + assert store.public_session(assistant)["manager_runtime"] is None + calls = [json.loads(line) for line in capture.read_text().splitlines()] + assert [call["params"]["sandbox"] for call in calls if call.get("method") == "thread/start"][-1] == "workspace-write" + + runtime.close() + restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=runtime.codex_bin, + project_contexts=runtime.project_contexts, registry_path=runtime.registry_path, + manager_scope_resolver=transport.bindings.steward_scope) + transport.bindings.controller = restarted + source = {"source_ref": trusted["steward_context"]["source_ref"], + "sender_ref": binding["operator_ref"], "private_human_message": True} + + def reopen(): + return restarted.open_session(goal_id=None, agent_id="codex", work_dir=store.root, objective="", + mode="resume_latest", conversation_binding_id=binding["binding_id"], source_context=source)[0] + + try: + resumed = reopen() + assert resumed["session_id"] == trusted["session_id"] + calls = [json.loads(line) for line in capture.read_text().splitlines()] + rpc = [call["params"] for call in calls if call.get("method") == "thread/resume"][-1] + assert rpc["threadId"] == trusted["upstream_thread_id"] + assert rpc["sandbox"] == "danger-full-access" + apply_profile(store.root.parent, "restricted") + revoked = reopen() + assert revoked["session_id"] == trusted["session_id"] + assert revoked["manager_runtime_sandbox"] == "read-only" + assert revoked["manager_runtime_standing_grant"] == "none" + calls = [json.loads(line) for line in capture.read_text().splitlines()] + assert [call["params"]["sandbox"] for call in calls if call.get("method") == "thread/start"][-1] == "read-only" + assert "Remember the owner's initial request." in str(store.messages(revoked["session_id"])) + finally: + restarted.close() + + +def test_private_owner_proof_is_rechecked_before_reusing_a_healthy_host(steward): # noqa: F811 + store, runtime, provider, transport, binding, capture, _ = steward + apply_profile(store.root.parent, "trusted_owner") + transport.admit("steward-app", provider.event("steward-app", "first", "An authorized request.")) + row = transport.core.pending()[0] + assert finish(runtime, row)["status"] == "completed" + session = store.load_session(row["session_id"]) + saved = session["steward_context"] + with pytest.raises(ValueError, match="audience changed"): + runtime.manager_runtime_profile("manager.external.other", steward_context=saved) + assert runtime.manager_runtime_profile(session["channel_id"])["runtime_profile"] == "restricted" + source = {"source_ref": saved["source_ref"], "sender_ref": binding["operator_ref"], "private_human_message": False} + with pytest.raises(ValueError, match="audience"): + transport.bindings.resolve(binding_id=binding["binding_id"], **source) + group_event = provider.event("steward-app", "group", "A group request.") + group_event["chat_type"] = "group" + assert transport.admit("steward-app", group_event)["status"] == "audience_rejected" + before = capture.read_bytes() + observation = transport.bindings.observe + transport.bindings.observe = lambda profile: {**observation(profile), "verified": False} + with pytest.raises(ValueError, match="verified"): + runtime.enqueue_turn(session_id=session["session_id"], client_turn_id="identity-lost", message="more", + work_dir=store.root, objective="", origin="lark") + assert store.turn_for_client(session["session_id"], "identity-lost") is None + assert capture.read_bytes() == before + transport.bindings.observe = observation + transport.bindings.disconnect(binding["binding_id"], expected_revision=transport.bindings.read()["revision"]) + with pytest.raises(ValueError, match="no longer authorized"): + runtime.manager_runtime_profile(session["channel_id"], steward_context=saved) + assert capture.read_bytes() == before + + +def test_workspace_only_steward_cannot_inherit_unbounded_host_grant(steward): # noqa: F811 + store, runtime, provider, transport, _, capture, _ = steward + runtime.project_contexts.filesystem_scope = "workspace_only" + apply_profile(store.root.parent, "trusted_owner") + transport.admit("steward-app", provider.event("steward-app", "bounded", "Stay within the public workspace.")) + row = transport.core.pending()[0] + assert finish(runtime, row)["status"] == "completed" + session = store.load_session(row["session_id"]) + assert session["manager_runtime_profile"] == "restricted" + assert session["manager_runtime_standing_grant"] == "none" + calls = [json.loads(line) for line in capture.read_text().splitlines()] + assert [call["params"]["sandbox"] for call in calls if call.get("method") == "thread/start"] == ["read-only"] + + +def test_portfolio_scope_refresh_preserves_the_verified_private_host_profile(steward): # noqa: F811 + store, runtime, provider, transport, _, capture, workspace = steward + apply_profile(store.root.parent, "trusted_owner") + + def admit(name, text): + transport.admit("steward-app", provider.event("steward-app", name, text)) + row = next(item for item in transport.core.pending() if item["message"] == text) + assert finish(runtime, row)["status"] == "completed" + return store.load_session(row["session_id"]) + + first = admit("empty", "Read the currently authorized portfolio.") + runtime.registry_path.write_text(json.dumps({"schema_version": "0.1", "goals": [ + {"id": "new-project", "repo": str(workspace)}]})) + refreshed = admit("expanded", "Read the newly registered project as well.") + assert refreshed["session_id"] == first["session_id"] + assert refreshed["manager_authorization_scope_id"] != first["manager_authorization_scope_id"] + assert transport.bindings.session_context(refreshed["steward_context"])["context"]["goal_ids"] == ["new-project"] + assert refreshed["manager_runtime_profile"] == "trusted_owner" + calls = [json.loads(line) for line in capture.read_text().splitlines()] + assert all(call["params"]["sandbox"] == "danger-full-access" + for call in calls if call.get("method") in {"thread/start", "thread/resume"}) From 8ef9aa3048b1e91737e27c3cacfcb665244de5c4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:16:49 +0800 Subject: [PATCH 2/2] docs(manager-runtime): clarify native private audience boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/manager-runtime-profile-v0.md | 27 +++++++++++++------ .../rfcs/manager-runtime-profile-v0.zh-CN.md | 22 ++++++++++----- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.md b/docs/architecture/rfcs/manager-runtime-profile-v0.md index 346d1ad225..c0938c4978 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.md @@ -35,9 +35,12 @@ read-only. not change. - Codex is currently the only endpoint that enforces `trusted_owner`. Other endpoints fail with an actionable typed error instead of pretending to provide the selected profile. -- `trusted_owner` currently applies only to the private owner-manager conversation. An external - audience, including a Lark group, is a separate trust boundary and resolves the same machine - choice to `restricted` until an existing audience/resource grant can be verified. +- `trusted_owner` applies to the local private owner-manager and a native private steward + binding freshly verified by the existing Core conversation owner. That owner checks the + App, owner principal, private source, current resource grant and frozen Session identity. + The audience proof alone grants no tools: the same persistent machine grant is also required. + Other external audiences, including Lark groups and workspace-only resources, remain + `restricted`. Project assistants retain their separate workspace grant. Configuration reuses the existing capability workbench and its `preview -> apply -> readback` transaction. There is no second configuration source. Missing @@ -55,18 +58,23 @@ without an unnecessary restart. Dashboard shows both machine configuration and current Session readback. CLI/managed Turn, Dashboard, and Lark all use the same manager runtime controller. Lark remains an entry point and -projection of that Session; it does not own a separate profile or permission state, but an external -audience currently degrades visibly to `restricted`. Future Lark host-tool access must reuse an -existing audience/resource authority instead of adding a manager-specific ACL here. +projection of that Session; it does not own a separate profile or permission state. Native +owner-private bindings reuse Core's fresh audience/resource verification before starting or +reusing a host; no channel-name inference, persisted proof or manager-specific ACL replaces it. +A revoked binding or changed App/owner stops admission. This slice implements only the private-owner M1 journey in [capable-manager-semantic-handoff-v0](capable-manager-semantic-handoff-v0.md), targeting A1–A3/A12. It does not implement the M2 collaboration request, the M3 outbox, or treat manager Session fields as work, request, or delivery authority. -### Implementation and successor (2026-09-16) +### Implementation and successor -`43d362532` contains the machine profile, controller integration and focused tests; passing them here is not deployment or full M1 qualification. The `restricted` default, Codex-only private `trusted_owner` and external-audience downgrade remain. Selecting DSH does not inherit that capable profile. Follow [roadmap](loopx-overall-roadmap-v0.md) R2 for actual tool/session/continued-execution and settings readback, without a second machine configuration. +`43d362532` introduced the machine profile. Native private steward bindings now carry a fresh +Core owner-audience proof into that existing profile at open, resume and authorization-scope +refresh. The installation default stays `restricted`; selecting DSH does not inherit the capable +profile. Source/RPC tests do not certify installed live-model work or desktop notification +delivery. Follow [roadmap](loopx-overall-roadmap-v0.md) R2 for those remaining qualifications. ### Acceptance @@ -83,3 +91,6 @@ as work, request, or delivery authority. fallback state. 8. An external audience without an existing scoped grant remains `read-only` and exposes the effective downgrade accurately. +9. A verified native private steward uses the explicit machine grant, preserves its Session + and history across profile changes/restarts, and rechecks binding/provider identity before + healthy-host reuse. Other Apps, groups and workspace-only resources do not inherit it. diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md index 82412a1b72..c5309950f1 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md @@ -30,9 +30,10 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 外部 provider 权限、受众边界和 LoopX durable state owner 不被改写。 - 当前只有 Codex endpoint 能执行 `trusted_owner`。选择其他 endpoint 时必须返回可恢复的 typed error,不能把受限执行伪装成已开放。 -- `trusted_owner` 当前只对私有 Owner 管家会话生效。外部 audience(包括 Lark 群)是独立 - 信任边界;在既有的 audience/resource grant 能被核验前,同一机器配置在那里仍解析为 - `restricted`,不能仅凭“同一管家”继承宿主资源权限。 +- `trusted_owner` 适用于本地私有 Owner 管家和由既有 Core conversation owner 重新核验的 + 原生本人管家私聊。Core 核验 App、Owner 身份、私聊来源、当前资源授权和冻结的 Session + 身份;受众证明本身不授予工具,仍须同一份显式机器授权。其他外部受众(包括 Lark 群) + 和 `workspace_only` 资源继续受限;个人助手保留独立的工作区授权。 机器配置沿用现有 capability workbench 的 `preview -> apply -> readback` 流程,不建立第二份 配置源。配置缺失时安全回退 `restricted`;配置损坏时也回退,并在能力投影中显示 @@ -47,16 +48,20 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 Dashboard 同时显示机器配置和当前会话 readback。CLI/managed Turn、Dashboard 与 Lark 继续调用同一个 manager runtime controller;Lark 是同源会话的入口和投影,不拥有独立 -profile 或权限状态,但当前外部 audience 会明确降级为 `restricted`。后续若开放 Lark -宿主工具,必须复用已有的 audience/resource authority,不在这里新增管家 ACL。 +profile 或权限状态。原生本人私聊在启动或复用宿主前重新核验 Core 的受众与资源授权; +不依据 channel 名字、持久化证明或新管家 ACL 推断权限。绑定撤销或 App/Owner 改变时 +停止准入。 本切片只实现 [capable-manager-semantic-handoff-v0](capable-manager-semantic-handoff-v0.zh-CN.md) 的 M1 私有 Owner 旅程,目标验收为 A1–A3/A12。它不实现 M2 collaboration request、M3 outbox,也不把管家 session 字段当成工作、请求或送达权威。 -### 实现与后继(2026-09-16) +### 实现与后继 -`43d362532` 已有 machine profile、controller 集成与聚焦测试;本次复跑通过不等于本机部署或完整 M1 资格。`restricted` 默认、仅 Codex 支持私人 `trusted_owner` 及外部受众降级保持。DSH 通道选择不继承该强能力 profile。按[统一路线](loopx-overall-roadmap-v0.zh-CN.md) R2 补真实工具/会话/连续执行与设置读回;不得新建第二份机器配置。 +`43d362532` 引入机器模式。原生本人管家私聊在打开、续接和授权范围刷新时,将 Core +重新核验的 Owner 受众证明传给既有模式。安装默认仍为 `restricted`;DSH 通道选择不继承 +该强能力模式。源码与 RPC 测试不证明已部署的真实模型工作或桌面通知送达;按 +[统一路线](loopx-overall-roadmap-v0.zh-CN.md) R2 完成这些剩余验收。 ### 验收 @@ -70,3 +75,6 @@ outbox,也不把管家 session 字段当成工作、请求或送达权威。 `restricted` 的动作提示。 7. 桌面和移动 Dashboard 显示有效 profile;配置损坏时显示回退状态。 8. 外部 audience 在没有既有 scoped grant 时继续 `read-only`,并显示真实降级状态。 +9. 已核验的原生本人管家私聊沿用显式机器授权,模式改变或服务重启保留 Session 与历史; + 复用健康宿主前重新核验绑定和 Provider 身份。其他 App、群聊和 `workspace_only` 资源 + 不继承该授权。