diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 9c0d8e933e..a81c949abb 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -94,6 +94,18 @@ authorize a restart. Discovery combines open home files with the private launch environment of Codex processes, covering warm app-servers that cached auth and closed their files. Arguments and environment values are never projected or logged. +Version 0.12.10 retries only the read-only ownership scan, at most three times, +when a consumer starts between process enumeration and file inspection or is +independently proven to have exited during that inspection. A stable fresh +snapshot must precede the existing service restart. PID reuse, an unreadable +live process and persistent churn remain holds; no shutdown or credential +transaction is retried by this scan. A failed environment read cannot turn an +unreadable live Codex process into a proven exit. + +0.12.10 对进程枚举与文件检查之间新启动、或已独立证明退出的占用,最多进行三次 +只读重扫,再以稳定的新身份清单进入原受控重启流程。PID 复用、仍存活但身份不明及 +持续变化继续等待;重扫不重放退出、凭据事务或业务,也不扩大 VM 的重启权限。 + Each service's restoration intent is saved privately before unloading. Version 0.12.7 waits up to five seconds for launchd to finish an acknowledged asynchronous unload; a job that remains loaded retains its recovery intent and fails without diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index 9049753d50..4dd7003b31 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.9" +version = "0.12.10" 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 d2a2af9f52..40e7c38e05 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.9" +version = "0.12.10" 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 0c0e8f0128..6ad7025538 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 @@ -148,6 +148,79 @@ def fixture(): home.mkdir(mode=0o700) return Fixture(home) + # A consumer born after process enumeration must enter a fresh ownership + # snapshot before the ordinary service stop/switch/restore can proceed. + # Conversely, lsof can still report a process which has just exited. + for change in ("born", "exited"): + f = fixture() + f.holders = [] if change == "born" else [301] + if change == "born": + f.records.pop(301) + normal_run, scans = f.run, [] + def churn_once(command, **kwargs): + if command[0] == "/usr/sbin/lsof": + scans.append(command) + if len(scans) == 1: + if change == "born": + f.records[301] = (300, "/test/codex", 901, 5) + f.holders = [301] + else: + f.records.pop(301) + f.holders = [] + return SimpleNamespace(returncode=0, stdout="p301\n") + return normal_run(command, **kwargs) + f.run = churn_once + with f.context(): + assert f.handoff()["active_slot"] == "b" + assert f.loaded and f.switches == 1 and len(scans) >= 2 + assert any(c[1] == "bootout" for c in f.calls) is (change == "born") + + # Persistent churn is bounded; reuse and unreadable live holders cannot be + # laundered into a new identity by the read-only retry. + for change in ("churn", "reuse", "unreadable"): + f = fixture() + f.holders = [301] + normal_run, scans = f.run, [] + def unstable_run(command, **kwargs): + if command[0] == "/usr/sbin/lsof": + scans.append(command) + if change == "churn": + pid = 700 + len(scans) + f.records[pid] = (300, "/test/codex", 2000 + pid, 1) + f.holders = [pid] + elif change == "reuse": + f.records[301] = (300, "/test/codex", 2000, 1) + else: + f.records[301] = None # kill(0) still confirms a live PID. + return normal_run(command, **kwargs) + f.run = unstable_run + with f.context(): + error("client_changed", f.host._home_owners) + assert len(scans) == (3 if change == "churn" else 1) + assert not f.killed and f.switches == 0 + assert not any(c[0] == "/bin/launchctl" and c[1] != "print" for c in f.calls) + + # Environment reads race with process exit too. A denied/unreadable live + # Codex process remains unknown, even if the kernel identity also disappears. + for exited in (False, True): + f = fixture() + f.records[200] = (1, "/test/codex", 999, 3) + before = dict(f.records) + def failed_environment(pid): + if pid != 200: + return None + if exited: + f.records.pop(200) + else: + f.records[200] = None + raise OSError("process environment unavailable") + with f.context(), patch.object(host_module, "codex_home", side_effect=failed_environment): + if exited: + assert not f.host._cached_codex_owners(before) + else: + error("ownership_unverified", lambda: f.host._cached_codex_owners(before)) + assert not f.killed and f.switches == 0 + # Service labels are installation details. A renamed same-home consumer # must be stopped and restored through its existing launch agent, including # when the plist filename differs from its launchd label. @@ -553,6 +626,32 @@ def responsible(_pid): child.terminate() child.wait(timeout=5) + # Real ps/kernel/lsof evidence, with an explicitly synchronized birth + # between the two reads. This disposable directory contains no credentials. + with tempfile.TemporaryDirectory() as temporary: + host = host_module.NativeHost.__new__(host_module.NativeHost) + host.spec = {"home": Path(temporary).resolve()} + enumerate_processes, consumer = host._processes, None + def enumerate_then_start(): + global consumer + records = enumerate_processes() + if consumer is None: + consumer = subprocess.Popen( + [sys.executable, "-c", "import time; print('ready', flush=True); time.sleep(20)"], + cwd=host.spec["home"], stdout=subprocess.PIPE, text=True) + assert consumer.stdout.readline().strip() == "ready" + return records + host._processes = enumerate_then_start + try: + owners = host._home_owners() + assert consumer is not None and consumer.pid in owners + assert host._owner_identities[consumer.pid] == host_module.process_identity(consumer.pid) + finally: + if consumer is not None: + consumer.terminate() + consumer.wait(timeout=5) + consumer.stdout.close() + # An argv lookalike cannot supply CODEX_HOME; empty argv entries do not shift # the environment boundary. Duplicate environment keys are unverifiable. def process_bytes(arguments, environment): 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 573d347859..964fe497bf 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.9" +__version__ = "0.12.10" 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 fde88773a1..fc2f0d5e7b 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 @@ -61,6 +61,17 @@ class BSDInfo(ctypes.Structure): return None +def process_exited(pid: int) -> bool: + """An unreadable kernel identity alone does not establish process exit.""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + except OSError: + pass + return False + + # Address one inspected process, not an App bundle id shared by other routes. # terminate() requests the normal AppKit quit lifecycle and can be refused. QUIT = '''ObjC.import("AppKit"); @@ -280,18 +291,32 @@ def reopen(self, expected: str) -> dict: return self.status() def _home_owners(self) -> list[int]: - processes = self._processes() - result = subprocess.run(["/usr/sbin/lsof", "-Fp", "+d", str(self.spec["home"])], - capture_output=True, text=True, timeout=10) - if result.returncode not in (0, 1): - raise HostError("ownership_unverified") - owners = {int(line[1:]) for line in result.stdout.splitlines() - if line.startswith("p") and line[1:].isdigit()} - owners |= self._cached_codex_owners(processes) - if any(processes.get(p) is None or process_identity(p) != processes[p] for p in owners): - raise HostError("client_changed") - self._owner_identities = {p: processes[p] for p in owners} - return sorted(owners) + # ps/kernel enumeration and lsof are not an atomic snapshot. A consumer + # born between them needs a fresh read, not a restart against an identity + # which was never inspected. Rescan only discovery; never retry effects. + for _ in range(3): + processes = self._processes() + result = subprocess.run(["/usr/sbin/lsof", "-Fp", "+d", str(self.spec["home"])], + capture_output=True, text=True, timeout=10) + if result.returncode not in (0, 1): + raise HostError("ownership_unverified") + owners = {int(line[1:]) for line in result.stdout.splitlines() + if line.startswith("p") and line[1:].isdigit()} + owners |= self._cached_codex_owners(processes) + unstable = False + for pid in owners: + current, previous = process_identity(pid), processes.get(pid) + if previous is not None and current == previous: + continue + if ((previous is None and current is not None) + or (current is None and process_exited(pid))): + unstable = True # A new or proven-exited process: re-read. + else: + raise HostError("client_changed") # Reuse/unknown is a hold. + if not unstable: + self._owner_identities = {p: processes[p] for p in owners} + return sorted(owners) + raise HostError("client_changed") def _processes(self) -> dict: result = subprocess.run(["/bin/ps", "-axo", "pid="], @@ -308,10 +333,13 @@ def _cached_codex_owners(self, processes: dict | None = None) -> set[int]: try: home = codex_home(pid) except OSError: - if process_identity(pid) is not None: - raise HostError("ownership_unverified") from None + if process_identity(pid) is None and process_exited(pid): + continue + raise HostError("ownership_unverified") from None + current = process_identity(pid) + if current is None and process_exited(pid): continue - if process_identity(pid) != identity: + if current != identity: raise HostError("client_changed") if home: if not Path(home).is_absolute(): @@ -375,12 +403,8 @@ def _helpers_stopped(self, helpers: dict) -> bool: if current[1:] == frozen: return False else: - try: - os.kill(pid, 0) - except ProcessLookupError: + if process_exited(pid): continue - except OSError: - pass return False # Unknown identity does not prove exit. return True @@ -606,13 +630,7 @@ def resume_chat_agents(self) -> None: if current is not None and list(current[1:]) == owner["identity"]: return # A concurrent status read must not undo a live handoff. if current is None: - try: - os.kill(owner["pid"], 0) - except ProcessLookupError: - pass - except OSError: - return - else: + if not process_exited(owner["pid"]): return # Unreadable identity does not prove worker exit. failed = False for label, entry in list(recovery["agents"].items()):