Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/loopx-codex-provider-routing/NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/extension.toml
Original file line number Diff line number Diff line change
@@ -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 = []

Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ["__version__"]

__version__ = "0.12.9"
__version__ = "0.12.10"
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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="],
Expand All @@ -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():
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()):
Expand Down
Loading