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
19 changes: 19 additions & 0 deletions packages/loopx-codex-provider-routing/NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.8"
version = "0.12.9"
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.8"
version = "0.12.9"
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 @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"}}))
Expand Down Expand Up @@ -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)")
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ["__version__"]

__version__ = "0.12.8"
__version__ = "0.12.9"
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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
Loading