From 84c37daeecec19035069c71be61bee284fa4f3f9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:39:15 +0800 Subject: [PATCH] fix(native-accounts): identify managers of orphaned auth holders Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 19 ++++ .../extension.toml | 2 +- .../pyproject.toml | 2 +- .../smoke/native_host_clients_smoke.py | 94 ++++++++++++++++++- .../loopx_codex_provider_routing/__init__.py | 2 +- .../native_host.py | 69 +++++++++++++- .../native_processes.py | 56 ++++++++++- 7 files changed, 233 insertions(+), 11 deletions(-) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 55c7d3819b..3f73bd4fa6 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -121,6 +121,25 @@ services without a proven restart owner) remain visible holds; installation does not turn them into termination targets. Discovery is best effort within the current user's kernel visibility, not a claim to inspect auth cached inside another user's process or a virtual machine. + +Version 0.12.9 also explains unmanaged holders through Darwin's optional kernel +responsibility probe. Reparented XPC processes can retain a different manager +from the current App. A responsible PID/name is diagnostic provenance, never +App ownership or restart permission. For a verified installed Lima hostagent, +the inventory cross-checks its private pidfile and the original Lima/Colima +catalog's running instance; raw arguments, environment and catalog paths stay +private. Missing SPI/permissions, changed launch identities, ambiguous instances +or unavailable manager reads preserve an explicit hold. No guest command runs. +The existing App/LaunchAgent restart path is unchanged; externally managed +workloads require their own idle/recovery qualification. This read-only CLI +diagnostic is a staged prerequisite: the settings page still shows its existing +busy cause, and VM recovery/new-auth adoption remain unqualified. + +0.12.9 的只读占用清单会核对脱离 App 进程树的 XPC 责任进程,并对已核验的 +Lima/Colima 管理器交叉读取原实例。识别到管理器不等于业务空闲或可以重启; +未知或变化的身份保留等待,不执行 guest 命令,不新增强关目标。设置页的完整 +占用诊断与外部工作负载恢复仍待交付,不能把这一步视为真实换号已生效。 + An operator-owned service independent of the App can run the same observer: ```sh diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index c7110dbe1c..9049753d50 100644 --- a/packages/loopx-codex-provider-routing/extension.toml +++ b/packages/loopx-codex-provider-routing/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-codex-provider-routing" -version = "0.12.8" +version = "0.12.9" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-codex-provider-routing/pyproject.toml b/packages/loopx-codex-provider-routing/pyproject.toml index 224fe98548..d2a2af9f52 100644 --- a/packages/loopx-codex-provider-routing/pyproject.toml +++ b/packages/loopx-codex-provider-routing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-codex-provider-routing" -version = "0.12.8" +version = "0.12.9" description = "Native Codex account slots and exhausted-account handoff for LoopX" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py b/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py index 504aaecd8b..0c0e8f0128 100644 --- a/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py +++ b/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py @@ -17,7 +17,7 @@ sys.path.insert(0, str(PACKAGE_ROOT / "src")) host_module = importlib.import_module("loopx_codex_provider_routing.native_host") from loopx_codex_provider_routing.native import private_read # noqa: E402 -from loopx_codex_provider_routing.native_processes import _codex_home, codex_home # noqa: E402 +from loopx_codex_provider_routing.native_processes import _codex_home, codex_home, lima_pidfile # noqa: E402 BUNDLE = "/Applications/Example.app" EXE = BUNDLE + "/Contents/MacOS/Example" @@ -121,6 +121,7 @@ def context(self): stack.enter_context(patch.object(host_module.sys, "platform", "darwin")) stack.enter_context(patch.object(host_module, "process_identity", side_effect=lambda p: self.records.get(p))) stack.enter_context(patch.object(host_module, "codex_home", return_value=None)) + stack.enter_context(patch.object(host_module, "responsible_pid", return_value=None)) stack.enter_context(patch.object(host_module.subprocess, "run", side_effect=self.run)) stack.enter_context(patch.object(host_module.os, "kill", side_effect=self.kill)) return stack @@ -457,6 +458,78 @@ def changed_run(command, **kwargs): error("state_untrusted", f.host.resume_chat_agents) assert not f.calls + # A reparented VM holds this home, but belongs to a separate manager, not + # the current App. Prove its instance using kernel responsibility, the + # exact pidfile and the manager's scoped JSON inventory. This is still a + # read-only hold: a running instance says nothing about workload idleness. + for mutation in (None, "unknown", "same_app_name", "pid_reused", "holder_reused", "changed_responsibility", + "wrong_instance", "wrong_catalog", "duplicate", "changed_pidfile", "manager_unavailable", + "untrusted_binary", "different_binary", "not_hostagent", "stopped"): + f = fixture() + vm = "/System/Library/Frameworks/Virtualization.framework/VirtualMachine" + binary = f.home / "limactl" + binary.write_text("synthetic manager; subprocess is intercepted") + binary.chmod(0o700) + instance_dir = f.home / ".colima/_lima/example-vm" + instance_dir.mkdir(parents=True) + pidfile = instance_dir / "ha.pid" + pidfile.write_text("700\n") + pidfile.chmod(0o600) + f.holders = [200] + f.records[200] = (1, vm, 100, 1) # Older than current App, PPID=1. + f.records[700] = (1, str(binary), 99, 2) + manager = {"name": "example-vm", "dir": str(instance_dir), "hostAgentPID": 700, "status": "Running"} + primary_run = f.run + def manager_run(command, **kwargs): + if command[0] == str(binary): + f.calls.append(command) + assert command == [str(binary), "list", "--format", "json"] + assert kwargs["env"]["LIMA_HOME"] == str(instance_dir.parent) + if mutation == "pid_reused": + f.records[700] = (1, str(binary), 5000, 5) + if mutation == "holder_reused": + f.records[200] = (1, vm, 5000, 5) + if mutation == "changed_pidfile": + pidfile.write_text("701\n") + if mutation == "manager_unavailable": + raise subprocess.TimeoutExpired(command, 5) + return SimpleNamespace(returncode=0, stdout=json.dumps(manager) + "\n" + + (json.dumps(manager) if mutation == "duplicate" else "")) + return primary_run(command, **kwargs) + f.run = manager_run + if mutation == "wrong_instance": + manager["dir"] = str(instance_dir.with_name("other-vm")) + if mutation == "stopped": + manager["status"] = "Stopped" + if mutation == "untrusted_binary": + binary.chmod(0o777) + if mutation == "same_app_name": + f.records[700] = (1, EXE, 99, 2) # Path/name still cannot grant App ownership. + responsibilities = iter((700, 701)) if mutation == "changed_responsibility" else None + def responsible(_pid): + return None if mutation == "unknown" else next(responsibilities) if responsibilities else 700 + with f.context(), patch.object(host_module, "responsible_pid", side_effect=responsible), \ + patch.object(host_module, "lima_pidfile", return_value=None if mutation == "not_hostagent" else + str(f.home / "other/ha.pid") if mutation == "wrong_catalog" else str(pidfile)), \ + patch.object(host_module.shutil, "which", return_value=str(binary.with_name("other")) + if mutation == "different_binary" else str(binary)): + if mutation in ("pid_reused", "holder_reused", "changed_responsibility"): + error("client_changed", f.host.clients) + else: + row = f.host.clients()["clients"][0] + assert row["kind"] == "unmanaged" and row["restartable"] is False + assert row["restart_blocker"] == ("manager_unverified" if mutation == "unknown" + else "external_workload_unverified") + if mutation is None: + assert row["manager"] == {"pid": 700, "process": "limactl", + "instance": "example-vm", "status": "Running"} + elif mutation != "unknown": + assert "instance" not in row["manager"] + # No raw command, path, environment, auth or private catalog. + assert str(f.home) not in json.dumps(row) + assert f.switches == 0 and not f.killed and not (f.root / "chat-recovery.json").exists() + assert not any(c[0] == "/bin/launchctl" and c[1] != "print" for c in f.calls) + f = fixture() (f.root / "chat-recovery.json").write_text(json.dumps({ "schema_version": "codex_native_accounts_v1", "agents": {"unrelated.service": "digest"}})) @@ -494,4 +567,23 @@ def process_bytes(arguments, environment): else: raise AssertionError("ambiguous environment accepted") +# A secret/argv lookalike or environment entry cannot supply the pidfile. +from loopx_codex_provider_routing import native_processes as process_module # noqa: E402 +for arguments, environment, expected in ( + ([b"limactl", b"hostagent", b"--pidfile=/example/ha.pid", b"private-secret"], [], "/example/ha.pid"), + ([b"limactl", b"hostagent", b"--pidfile", b"/example/ha.pid"], [], "/example/ha.pid"), + ([b"limactl", b"shell"], [b"--pidfile=/decoy"], None), + ([b"limactl", b"hostagent", b"--pidfile=/one", b"--pidfile=/two"], [], None), + ([b"limactl", b"hostagent", b"--pidfile"], [], None), + ([b"limactl", b"hostagent", b"--pidfile=relative"], [], None), +): + with patch.object(process_module, "_procargs", return_value=process_bytes(arguments, environment)): + assert lima_pidfile(123) == expected + +# Optional private SPI absence or denial remains unknown; no name fallback. +for failure in (OSError("denied"), AttributeError("symbol absent")): + with patch.object(process_module.sys, "platform", "darwin"), \ + patch.object(process_module.ctypes, "CDLL", side_effect=failure): + assert process_module.responsible_pid(123) is None + print("native host client handoff smoke passed (identity, scope, switch and recovery)") diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py index c08e33c9d0..573d347859 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.12.8" +__version__ = "0.12.9" diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py index 3b61406587..fde88773a1 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py @@ -26,7 +26,7 @@ import urllib.request import urllib.parse -from .native_processes import codex_home +from .native_processes import codex_home, lima_pidfile, responsible_pid class HostError(Exception): @@ -425,6 +425,70 @@ def _consumer_agents(self, owners: list[int]) -> list[tuple]: agents.append((label, plist, service_pid, service, helpers)) return agents + def _lima_instance(self, pid: int, executable: str) -> dict | None: + """Corroborate hostagent ownership with its original manager, read-only.""" + try: + binary = trusted_file(Path(executable)) + installed = shutil.which("limactl") + if installed is None or binary != trusted_file(Path(installed)): + return None # A matching basename is not an executable allowlist. + pidfile_value = lima_pidfile(pid) + if pidfile_value is None: + return None + pidfile = Path(pidfile_value) + # Only the user's normal Lima/Colima catalogs. A private argv path + # must not authorize arbitrary file reads or a different catalog. + catalogs = {(self.home / ".lima").resolve(), (self.home / ".colima/_lima").resolve()} + if (pidfile.name != "ha.pid" or pidfile.parent.parent not in catalogs + or pidfile.resolve() != pidfile): + return None + raw = trusted_file(pidfile).read_bytes() + if len(raw) > 32 or raw.strip() != str(pid).encode(): + return None + result = subprocess.run([str(binary), "list", "--format", "json"], + env={**os.environ, "LIMA_HOME": str(pidfile.parent.parent)}, + capture_output=True, text=True, timeout=5) + if result.returncode or len(result.stdout) > 1048576: + return None + instances = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + matches = [value for value in instances if isinstance(value, dict) + and value.get("dir") == str(pidfile.parent) + and type(value.get("hostAgentPID")) is int and value["hostAgentPID"] == pid] + if len(matches) != 1 or trusted_file(pidfile).read_bytes() != raw: + return None + value = matches[0] + name = value.get("name") + if (not isinstance(name, str) or name != pidfile.parent.name + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) is None + or value.get("status") != "Running"): + return None + return {"instance": name, "status": "Running"} + except (HostError, OSError, ValueError, subprocess.SubprocessError): + return None # Missing corroboration does not invent a restart owner. + + def _external_manager(self, pid: int) -> dict: + """Explain an unmanaged holder without granting new shutdown authority.""" + holder = self._owner_identities[pid] + if process_identity(pid) != holder: + raise HostError("client_changed") + manager_pid = responsible_pid(pid) + manager = process_identity(manager_pid) if manager_pid else None + result = {"restart_blocker": "manager_unverified"} + if manager is not None: + provenance = {"pid": manager_pid, "process": Path(manager[1]).name} + if Path(manager[1]).name == "limactl": + instance = self._lima_instance(manager_pid, manager[1]) + if instance: + provenance.update(instance) + # Neither an inherited App coalition name nor this relationship + # proves guest idleness, recoverability or authority over its jobs. + result = {"manager": provenance, "restart_blocker": "external_workload_unverified"} + if process_identity(manager_pid) != manager or responsible_pid(pid) != manager_pid: + raise HostError("client_changed") + if process_identity(pid) != holder: + raise HostError("client_changed") + return result + def clients(self) -> dict: if sys.platform != "darwin": raise HostError("platform_unavailable") @@ -436,7 +500,8 @@ def clients(self) -> dict: {"pid": p, "kind": "app" if p in app else "service" if p in services else "unmanaged", "process": Path(self._owner_identities[p][1]).name, "restartable": p in app or p in services, - **({"service": services[p]} if p in services else {})} for p in owners]} + **({"service": services[p]} if p in services else {}), + **(self._external_manager(p) if p not in app and p not in services else {})} for p in owners]} def _chat_recovery(self) -> tuple[Path, dict]: from .native import SCHEMA, private_read diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_processes.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_processes.py index e1260fc086..1b22e22d39 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_processes.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_processes.py @@ -1,7 +1,7 @@ """Darwin process IO for native account consumer discovery. -Process arguments/environment never leave this adapter. Only CODEX_HOME is -returned; the caller fences the read with kernel executable/start identity. +Process arguments/environment never leave this adapter. Only CODEX_HOME or a +Lima hostagent's pidfile is returned; callers fence kernel launch identity. This is discovery evidence, not authorization to signal an arbitrary process. """ from __future__ import annotations @@ -13,6 +13,45 @@ def codex_home(pid: int) -> str | None: """Read the launch environment without putting arguments/secrets in ps output.""" + return _codex_home(_procargs(pid)) + + +def responsible_pid(pid: int) -> int | None: + """Best-effort Darwin responsibility, including reparented XPC processes. + + This optional SPI can be absent or denied. A responsible PID is provenance + for diagnostics only, never permission to terminate it or its workload. + """ + if sys.platform != "darwin": + return None + try: + probe = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True).responsibility_get_pid_responsible_for_pid + probe.argtypes, probe.restype = [ctypes.c_int], ctypes.c_int + result = probe(pid) + return result if result > 1 and result != pid else None + except (AttributeError, OSError): + return None + + +def lima_pidfile(pid: int) -> str | None: + """Return just the explicit hostagent pidfile, never raw argv or env.""" + arguments, _ = _split_procargs(_procargs(pid)) + if len(arguments) < 2 or os.path.basename(arguments[0]) != b"limactl" or arguments[1] != b"hostagent": + return None + values = [] + for index, value in enumerate(arguments[2:], 2): + if value == b"--pidfile": + if index + 1 >= len(arguments): + return None + values.append(arguments[index + 1]) + elif value.startswith(b"--pidfile="): + values.append(value[len(b"--pidfile="):]) + if len(values) != 1 or not values[0].startswith(b"/"): + return None + return os.fsdecode(values[0]) + + +def _procargs(pid: int) -> bytes: if sys.platform != "darwin": raise OSError("process environment unavailable") libc = ctypes.CDLL(None, use_errno=True) @@ -23,10 +62,10 @@ def codex_home(pid: int) -> str | None: buffer = ctypes.create_string_buffer(size.value) if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0) != 0: raise OSError("process environment unavailable") - return _codex_home(buffer.raw[:size.value]) + return buffer.raw[:size.value] -def _codex_home(raw: bytes) -> str | None: +def _split_procargs(raw: bytes) -> tuple[list[bytes], list[bytes]]: argc = int.from_bytes(raw[:4], sys.byteorder, signed=True) if not 0 < argc <= 65536: raise OSError("invalid process environment") @@ -35,12 +74,19 @@ def _codex_home(raw: bytes) -> str | None: raise OSError("invalid process environment") while position < len(raw) and raw[position] == 0: position += 1 + arguments = [] for _ in range(argc): end = raw.find(b"\0", position) if end < 0: raise OSError("invalid process environment") + arguments.append(raw[position:end]) position = end + 1 - values = [value[11:] for value in raw[position:].split(b"\0") if value.startswith(b"CODEX_HOME=")] + return arguments, raw[position:].split(b"\0") + + +def _codex_home(raw: bytes) -> str | None: + _, environment = _split_procargs(raw) + values = [value[11:] for value in environment if value.startswith(b"CODEX_HOME=")] if len(values) > 1: raise OSError("ambiguous process environment") return os.fsdecode(values[0]) if values else None