900 lines
31 KiB
Python
Executable File
900 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Low-level utilities shared by Codex MMO.
|
|
|
|
The Python control plane intentionally has no third-party package dependency;
|
|
documented external executables still apply to the selected execution path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime as dt
|
|
import fcntl
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import tempfile
|
|
import time
|
|
import tomllib
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from mmo_version import MMO_SCHEMA_VERSION, PACKAGE_VERSION
|
|
|
|
ID_PATTERN = re.compile(r"[a-z][a-z0-9_.-]{1,63}")
|
|
SAFE_JOB_ID = re.compile(r"[A-Za-z0-9_.-]{8,128}")
|
|
|
|
_URI_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
|
|
_URI_UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
|
_URI_SUB_DELIMS = frozenset("!$&'()*+,;=")
|
|
_URI_HEXDIGITS = frozenset("0123456789ABCDEFabcdef")
|
|
_URI_PCHAR = _URI_UNRESERVED | _URI_SUB_DELIMS | frozenset(":@")
|
|
|
|
|
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Keep readiness checks scoped to the configured endpoint response."""
|
|
|
|
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
return None
|
|
|
|
|
|
def utc_now() -> str:
|
|
return dt.datetime.now(dt.UTC).isoformat(timespec="milliseconds")
|
|
|
|
|
|
def shell_exit_status(returncode: int) -> int:
|
|
"""Translate a Python child return code to the shell's signal convention."""
|
|
|
|
return 128 - returncode if returncode < 0 else returncode
|
|
|
|
|
|
def install_root() -> Path:
|
|
override = os.environ.get("MMO_INSTALL_ROOT")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
return Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def package_version() -> str:
|
|
return PACKAGE_VERSION
|
|
|
|
|
|
def install_runtime_path() -> Path:
|
|
return install_root() / "config" / "runtime.json"
|
|
|
|
|
|
def load_install_runtime() -> dict[str, Any]:
|
|
path = install_runtime_path()
|
|
try:
|
|
data = strict_json_loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError as exc:
|
|
raise RuntimeError(f"installed runtime configuration is missing: {path}") from exc
|
|
if not isinstance(data, dict):
|
|
raise RuntimeError(f"installed runtime configuration root must be an object: {path}")
|
|
schema_version = data.get("schema_version")
|
|
if (
|
|
not isinstance(schema_version, int)
|
|
or isinstance(schema_version, bool)
|
|
or schema_version != MMO_SCHEMA_VERSION
|
|
):
|
|
raise RuntimeError(f"unsupported installed runtime schema in {path}")
|
|
for key in ("install_root", "config_root", "state_root", "bin_dir", "python_bin"):
|
|
value = data.get(key)
|
|
if not isinstance(value, str) or not value:
|
|
raise RuntimeError(f"installed runtime field {key!r} is invalid in {path}")
|
|
for key in ("install_root", "config_root", "state_root", "bin_dir"):
|
|
if not Path(data[key]).expanduser().is_absolute():
|
|
raise RuntimeError(f"installed runtime path {key!r} is not absolute in {path}")
|
|
return data
|
|
|
|
|
|
def valid_http_header_value(value: str) -> bool:
|
|
"""Match the byte validity rule used by Rust's ``http::HeaderValue``.
|
|
|
|
RFC 9110 permits HTAB plus visible/obsolete field-value bytes. The Rust
|
|
HTTP stack used by both Codex and Switchyard rejects every other C0 control
|
|
byte and DEL. Encoding first also rejects lone Python surrogates.
|
|
"""
|
|
|
|
try:
|
|
encoded = value.encode("utf-8")
|
|
except UnicodeEncodeError:
|
|
return False
|
|
return all(byte == 0x09 or (byte >= 0x20 and byte != 0x7F) for byte in encoded)
|
|
|
|
|
|
def _valid_uri_component(value: str, allowed: frozenset[str]) -> bool:
|
|
"""Validate one RFC 3986 component, including percent triplets."""
|
|
|
|
index = 0
|
|
while index < len(value):
|
|
character = value[index]
|
|
if character == "%":
|
|
if (
|
|
index + 2 >= len(value)
|
|
or value[index + 1] not in _URI_HEXDIGITS
|
|
or value[index + 2] not in _URI_HEXDIGITS
|
|
):
|
|
return False
|
|
index += 3
|
|
continue
|
|
if character not in allowed:
|
|
return False
|
|
index += 1
|
|
return True
|
|
|
|
|
|
def _valid_uri_authority(authority: str) -> bool:
|
|
"""Validate RFC 3986 authority syntax without performing DNS resolution."""
|
|
|
|
if authority.count("@") > 1:
|
|
return False
|
|
if "@" in authority:
|
|
userinfo, host_port = authority.split("@", 1)
|
|
if not _valid_uri_component(userinfo, _URI_UNRESERVED | _URI_SUB_DELIMS | frozenset(":")):
|
|
return False
|
|
else:
|
|
host_port = authority
|
|
|
|
if host_port.startswith("["):
|
|
closing = host_port.find("]")
|
|
if closing < 0:
|
|
return False
|
|
literal = host_port[1:closing]
|
|
remainder = host_port[closing + 1 :]
|
|
if remainder and not remainder.startswith(":"):
|
|
return False
|
|
if remainder[1:] and (
|
|
not remainder[1:].isascii()
|
|
or not all("0" <= character <= "9" for character in remainder[1:])
|
|
):
|
|
return False
|
|
ipv_future = re.fullmatch(
|
|
r"[Vv][0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+",
|
|
literal,
|
|
re.ASCII,
|
|
)
|
|
if ipv_future is None:
|
|
try:
|
|
ipaddress.IPv6Address(literal)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
if "[" in host_port or "]" in host_port or host_port.count(":") > 1:
|
|
return False
|
|
if ":" in host_port:
|
|
host, port = host_port.rsplit(":", 1)
|
|
if port and (not port.isascii() or not all("0" <= character <= "9" for character in port)):
|
|
return False
|
|
else:
|
|
host = host_port
|
|
# A digit-and-dot string that is not an IPv4 address remains a valid
|
|
# reg-name. RFC 3986 deliberately gives a valid IPv4 address first-match
|
|
# precedence; it does not otherwise reserve that spelling.
|
|
return _valid_uri_component(host, _URI_UNRESERVED | _URI_SUB_DELIMS)
|
|
|
|
|
|
def valid_absolute_uri(value: str) -> bool:
|
|
"""Return whether ``value`` is an absolute URI under RFC 3986.
|
|
|
|
This is syntax validation only: it neither resolves hostnames nor imposes
|
|
scheme-specific semantics. URI references are intentionally rejected.
|
|
"""
|
|
|
|
if not isinstance(value, str):
|
|
return False
|
|
colon = value.find(":")
|
|
if colon <= 0 or _URI_SCHEME.fullmatch(value[:colon]) is None:
|
|
return False
|
|
remainder = value[colon + 1 :]
|
|
|
|
if "#" in remainder:
|
|
hierarchical, fragment = remainder.split("#", 1)
|
|
if not _valid_uri_component(fragment, _URI_PCHAR | frozenset("/?")):
|
|
return False
|
|
else:
|
|
hierarchical = remainder
|
|
if "?" in hierarchical:
|
|
hierarchical, query = hierarchical.split("?", 1)
|
|
if not _valid_uri_component(query, _URI_PCHAR | frozenset("/?")):
|
|
return False
|
|
|
|
if hierarchical.startswith("//"):
|
|
authority_and_path = hierarchical[2:]
|
|
slash = authority_and_path.find("/")
|
|
if slash < 0:
|
|
authority, path = authority_and_path, ""
|
|
else:
|
|
authority, path = authority_and_path[:slash], authority_and_path[slash:]
|
|
return _valid_uri_authority(authority) and _valid_uri_component(
|
|
path, _URI_PCHAR | frozenset("/")
|
|
)
|
|
|
|
# The remaining hier-part alternatives are path-absolute, path-rootless,
|
|
# and path-empty. A leading "//" was handled above; every non-empty
|
|
# rootless path therefore has the required non-empty first segment.
|
|
return _valid_uri_component(hierarchical, _URI_PCHAR | frozenset("/"))
|
|
|
|
|
|
def config_root(runtime: Mapping[str, Any] | None = None) -> Path:
|
|
override = os.environ.get("MMO_CONFIG_ROOT")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
data = runtime or load_install_runtime()
|
|
return Path(str(data["config_root"])).expanduser().resolve()
|
|
|
|
|
|
def state_root(runtime: Mapping[str, Any] | None = None) -> Path:
|
|
override = os.environ.get("MMO_STATE_ROOT")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
data = runtime or load_install_runtime()
|
|
return Path(str(data["state_root"])).expanduser().resolve()
|
|
|
|
|
|
def atomic_write_bytes(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, "wb") as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(temporary, mode)
|
|
os.replace(temporary, path)
|
|
with contextlib.suppress(OSError):
|
|
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
finally:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(temporary)
|
|
|
|
|
|
def atomic_write_text(path: Path, text: str, mode: int = 0o600) -> None:
|
|
atomic_write_bytes(path, text.encode("utf-8"), mode)
|
|
|
|
|
|
def atomic_write_json(path: Path, data: Any, mode: int = 0o600) -> None:
|
|
atomic_write_text(
|
|
path,
|
|
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
|
mode,
|
|
)
|
|
|
|
|
|
def _reject_json_constant(value: str) -> Any:
|
|
raise ValueError(f"non-standard JSON constant: {value}")
|
|
|
|
|
|
def _finite_json_float(value: str) -> float:
|
|
parsed = float(value)
|
|
if not math.isfinite(parsed):
|
|
raise ValueError(f"JSON number is outside the supported finite range: {value}")
|
|
return parsed
|
|
|
|
|
|
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, item in pairs:
|
|
if key in result:
|
|
raise ValueError(f"duplicate JSON object member: {key!r}")
|
|
result[key] = item
|
|
return result
|
|
|
|
|
|
def validate_json_unicode(value: Any) -> None:
|
|
"""Reject strings that contain lone UTF-16 surrogate code points."""
|
|
|
|
if isinstance(value, str):
|
|
try:
|
|
value.encode("utf-8")
|
|
except UnicodeEncodeError as exc:
|
|
raise ValueError("JSON strings must contain valid Unicode scalar values") from exc
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
validate_json_unicode(item)
|
|
elif isinstance(value, Mapping):
|
|
for key, item in value.items():
|
|
validate_json_unicode(key)
|
|
validate_json_unicode(item)
|
|
|
|
|
|
def strict_json_decoder() -> json.JSONDecoder:
|
|
"""Build the decoder shared by full-document and embedded JSON parsing."""
|
|
|
|
return json.JSONDecoder(
|
|
parse_constant=_reject_json_constant,
|
|
parse_float=_finite_json_float,
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
|
|
|
|
def strict_json_loads(value: str | bytes | bytearray) -> Any:
|
|
"""Parse interoperable RFC 8259 JSON without ambiguous extensions."""
|
|
|
|
parsed = json.loads(
|
|
value,
|
|
parse_constant=_reject_json_constant,
|
|
parse_float=_finite_json_float,
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
validate_json_unicode(parsed)
|
|
return parsed
|
|
|
|
|
|
def read_json(path: Path) -> Any:
|
|
return strict_json_loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def read_json_object(path: Path, *, label: str = "JSON document") -> dict[str, Any]:
|
|
value = read_json(path)
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} root must be an object: {path}")
|
|
return value
|
|
|
|
|
|
def read_toml(path: Path) -> dict[str, Any]:
|
|
try:
|
|
with path.open("rb") as handle:
|
|
value = tomllib.load(handle)
|
|
except tomllib.TOMLDecodeError as exc:
|
|
raise ValueError(f"invalid TOML in {path}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"TOML root must be a table: {path}")
|
|
return value
|
|
|
|
|
|
def canonical_json_bytes(value: Any) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def stable_hash(value: Any) -> str:
|
|
return sha256_bytes(canonical_json_bytes(value))
|
|
|
|
|
|
def validate_id(value: str, label: str = "identifier") -> str:
|
|
if not isinstance(value, str) or not ID_PATTERN.fullmatch(value):
|
|
raise ValueError(
|
|
f"invalid {label} {value!r}; use 2-64 lowercase letters, digits, '.', '_' or '-'"
|
|
)
|
|
return value
|
|
|
|
|
|
def safe_name(value: str) -> str:
|
|
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-.")
|
|
return cleaned[:80] or "item"
|
|
|
|
|
|
def toml_quote(value: str) -> str:
|
|
try:
|
|
value.encode("utf-8")
|
|
except UnicodeEncodeError as exc:
|
|
raise ValueError("generated TOML strings must contain valid Unicode scalar values") from exc
|
|
# JSON and TOML basic-string escapes overlap for the values emitted here,
|
|
# except that JSON permits a raw DEL character while TOML forbids it.
|
|
return json.dumps(value, ensure_ascii=False, allow_nan=False).replace("\x7f", "\\u007F")
|
|
|
|
|
|
def _toml_scalar(value: Any) -> str:
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, int):
|
|
return str(value)
|
|
if isinstance(value, float):
|
|
if value != value or value in (float("inf"), float("-inf")):
|
|
raise ValueError("non-finite floats are not supported in generated TOML")
|
|
return repr(value)
|
|
if isinstance(value, str):
|
|
return toml_quote(value)
|
|
if isinstance(value, list):
|
|
if any(isinstance(item, dict) for item in value):
|
|
raise TypeError("array-of-table values are emitted separately")
|
|
return "[" + ", ".join(_toml_scalar(item) for item in value) + "]"
|
|
if value is None:
|
|
raise TypeError("TOML has no null value")
|
|
raise TypeError(f"unsupported TOML scalar: {type(value).__name__}")
|
|
|
|
|
|
def toml_dumps(data: Mapping[str, Any]) -> str:
|
|
"""Serialize the subset of TOML used by this project deterministically."""
|
|
|
|
lines: list[str] = []
|
|
|
|
def emit_table(table: Mapping[str, Any], path: tuple[str, ...], header: bool) -> None:
|
|
scalar_items: list[tuple[str, Any]] = []
|
|
child_tables: list[tuple[str, Mapping[str, Any]]] = []
|
|
arrays_of_tables: list[tuple[str, list[Mapping[str, Any]]]] = []
|
|
for key in sorted(table):
|
|
value = table[key]
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, Mapping):
|
|
child_tables.append((key, value))
|
|
elif (
|
|
isinstance(value, list)
|
|
and value
|
|
and all(isinstance(item, Mapping) for item in value)
|
|
):
|
|
arrays_of_tables.append((key, value))
|
|
else:
|
|
scalar_items.append((key, value))
|
|
|
|
if header:
|
|
if lines and lines[-1] != "":
|
|
lines.append("")
|
|
lines.append("[" + ".".join(toml_quote(part) for part in path) + "]")
|
|
for key, value in scalar_items:
|
|
lines.append(f"{toml_quote(key)} = {_toml_scalar(value)}")
|
|
|
|
for key, child in child_tables:
|
|
emit_table(child, path + (key,), True)
|
|
for key, values in arrays_of_tables:
|
|
for item in values:
|
|
if lines and lines[-1] != "":
|
|
lines.append("")
|
|
item_path = path + (key,)
|
|
lines.append("[[" + ".".join(toml_quote(part) for part in item_path) + "]]")
|
|
item_scalars = {
|
|
item_key: item_value
|
|
for item_key, item_value in item.items()
|
|
if not isinstance(item_value, Mapping)
|
|
}
|
|
nested = {
|
|
item_key: item_value
|
|
for item_key, item_value in item.items()
|
|
if isinstance(item_value, Mapping)
|
|
}
|
|
for item_key in sorted(item_scalars):
|
|
lines.append(f"{toml_quote(item_key)} = {_toml_scalar(item_scalars[item_key])}")
|
|
for nested_key, nested_value in sorted(nested.items()):
|
|
emit_table(nested_value, item_path + (nested_key,), True)
|
|
|
|
emit_table(data, (), False)
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
result: dict[str, Any] = dict(base)
|
|
for key, value in override.items():
|
|
existing = result.get(key)
|
|
if isinstance(existing, Mapping) and isinstance(value, Mapping):
|
|
result[key] = deep_merge(existing, value)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def parse_env_file(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
if not path.is_file():
|
|
return values
|
|
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[7:].lstrip()
|
|
if "=" not in line:
|
|
raise ValueError(f"invalid environment assignment at {path}:{number}")
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
raise ValueError(f"invalid environment variable at {path}:{number}: {key!r}")
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
quote = value[0]
|
|
value = value[1:-1]
|
|
if quote == '"':
|
|
decoded: list[str] = []
|
|
index = 0
|
|
escapes = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
while index < len(value):
|
|
if value[index] != "\\" or index + 1 >= len(value):
|
|
decoded.append(value[index])
|
|
index += 1
|
|
continue
|
|
escaped = value[index + 1]
|
|
if escaped in escapes:
|
|
decoded.append(escapes[escaped])
|
|
else:
|
|
# Preserve unknown escapes literally. This avoids the
|
|
# lossy/non-ASCII behavior of ``unicode_escape`` while
|
|
# remaining compatible with ordinary dotenv values.
|
|
decoded.extend(("\\", escaped))
|
|
index += 2
|
|
value = "".join(decoded)
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def filtered_environment(
|
|
*,
|
|
allow_sensitive: Iterable[str] = (),
|
|
extra: Mapping[str, str] | None = None,
|
|
) -> dict[str, str]:
|
|
sensitive = re.compile(
|
|
r"(?:^|_)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|AUTH|PRIVATE)(?:_|$)",
|
|
re.IGNORECASE,
|
|
)
|
|
allowed = set(allow_sensitive)
|
|
result: dict[str, str] = {}
|
|
for key, value in os.environ.items():
|
|
if key.startswith("MMO_"):
|
|
continue
|
|
if sensitive.search(key) and key not in allowed:
|
|
continue
|
|
result[key] = value
|
|
if extra:
|
|
result.update(extra)
|
|
return result
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def file_lock(path: Path) -> Iterator[None]:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
with path.open("a+", encoding="utf-8") as handle:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
def _proc_stat_fields(value: str) -> list[str]:
|
|
"""Return Linux ``/proc/<pid>/stat`` fields beginning with process state.
|
|
|
|
The parenthesized command name may contain spaces and closing parentheses,
|
|
so tokenizing the complete record is not safe. The kernel's fields after
|
|
the final closing parenthesis have a stable whitespace-delimited layout.
|
|
"""
|
|
|
|
closing = value.rfind(")")
|
|
if closing < 0:
|
|
return []
|
|
return value[closing + 1 :].split()
|
|
|
|
|
|
def process_alive(pid: int | None) -> bool:
|
|
if not pid or pid <= 0:
|
|
return False
|
|
# kill(pid, 0) reports unreaped zombies as alive. On Linux this caused
|
|
# cancellation and gateway shutdown to consume the full grace period even
|
|
# though the process had already exited. Treat a /proc zombie as terminal.
|
|
stat = Path(f"/proc/{pid}/stat")
|
|
with contextlib.suppress(OSError):
|
|
fields = _proc_stat_fields(stat.read_text(encoding="ascii", errors="replace"))
|
|
if fields and fields[0] == "Z":
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
return True
|
|
|
|
|
|
def process_start_token(pid: int | None) -> str | None:
|
|
"""Return the Linux process start-time token used to detect PID reuse."""
|
|
|
|
if not pid or pid <= 0:
|
|
return None
|
|
try:
|
|
value = Path(f"/proc/{pid}/stat").read_text(encoding="ascii", errors="replace")
|
|
fields = _proc_stat_fields(value)
|
|
return fields[19]
|
|
except (OSError, IndexError):
|
|
return None
|
|
|
|
|
|
def process_matches(pid: int | None, start_token: object = None) -> bool:
|
|
"""Return whether *pid* is alive and still denotes the recorded process."""
|
|
|
|
if start_token is None or not process_alive(pid):
|
|
return False
|
|
return process_start_token(pid) == str(start_token)
|
|
|
|
|
|
def process_group_alive(pgid: int | None) -> bool:
|
|
if not pgid or pgid <= 1:
|
|
return False
|
|
proc = Path("/proc")
|
|
if proc.is_dir():
|
|
inspected = False
|
|
for stat_path in proc.glob("[0-9]*/stat"):
|
|
try:
|
|
value = stat_path.read_text(encoding="ascii", errors="replace")
|
|
fields = _proc_stat_fields(value)
|
|
inspected = True
|
|
if int(fields[2]) == pgid and fields[0] != "Z":
|
|
return True
|
|
except (OSError, ValueError, IndexError):
|
|
continue
|
|
if inspected:
|
|
return False
|
|
try:
|
|
os.killpg(pgid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
return True
|
|
|
|
|
|
def terminate_process_group(pgid: int, grace_seconds: float = 8.0) -> None:
|
|
"""Terminate an isolated process group, including after its leader exits."""
|
|
|
|
if not process_group_alive(pgid):
|
|
return
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.killpg(pgid, signal.SIGTERM)
|
|
deadline = time.monotonic() + max(0.1, grace_seconds)
|
|
while time.monotonic() < deadline:
|
|
if not process_group_alive(pgid):
|
|
return
|
|
time.sleep(0.05)
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
# Give the kernel a bounded interval to retire the process group. This is
|
|
# especially important for archive/release tests, which must not leave
|
|
# detached descendants behind after a forced cancellation.
|
|
kill_deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < kill_deadline:
|
|
if not process_group_alive(pgid):
|
|
return
|
|
time.sleep(0.02)
|
|
|
|
|
|
def terminate_process(pid: int, grace_seconds: float = 8.0) -> None:
|
|
"""Terminate one process when it cannot safely own an isolated group."""
|
|
|
|
if not process_alive(pid):
|
|
return
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.kill(pid, signal.SIGTERM)
|
|
deadline = time.monotonic() + max(0.1, grace_seconds)
|
|
while time.monotonic() < deadline:
|
|
if not process_alive(pid):
|
|
return
|
|
time.sleep(0.05)
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.kill(pid, signal.SIGKILL)
|
|
kill_deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < kill_deadline:
|
|
if not process_alive(pid):
|
|
return
|
|
time.sleep(0.02)
|
|
|
|
|
|
def is_within(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.relative_to(root)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def resolve_inside(value: str | Path, root: Path, *, must_exist: bool = False) -> Path:
|
|
candidate = Path(value).expanduser()
|
|
if not candidate.is_absolute():
|
|
candidate = root / candidate
|
|
resolved = candidate.resolve(strict=must_exist)
|
|
if not is_within(resolved, root):
|
|
raise ValueError(f"path escapes allowed root {root}: {value}")
|
|
return resolved
|
|
|
|
|
|
def copy_tree_static(source: Path, destination: Path) -> None:
|
|
"""Copy a static profile tree while rejecting links and special files."""
|
|
|
|
for path in sorted(source.rglob("*")):
|
|
relative = path.relative_to(source)
|
|
target = destination / relative
|
|
if path.is_symlink():
|
|
raise ValueError(f"symbolic links are not allowed in profile packs: {relative}")
|
|
if path.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
continue
|
|
if not path.is_file():
|
|
raise ValueError(f"special files are not allowed in profile packs: {relative}")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(path, target)
|
|
os.chmod(target, 0o644)
|
|
|
|
|
|
def make_tree_read_only(root: Path) -> None:
|
|
for path in sorted(root.rglob("*"), reverse=True):
|
|
if path.is_dir():
|
|
os.chmod(path, 0o555)
|
|
elif path.is_file():
|
|
os.chmod(path, 0o444)
|
|
os.chmod(root, 0o555)
|
|
|
|
|
|
def http_json(url: str, *, timeout: float = 10.0) -> Any:
|
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return strict_json_loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def http_ready(url: str, *, timeout: float = 0.5) -> bool:
|
|
try:
|
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
opener = urllib.request.build_opener(_NoRedirectHandler())
|
|
with opener.open(request, timeout=timeout) as response:
|
|
return 200 <= response.status < 300
|
|
except urllib.error.HTTPError as exc:
|
|
exc.close()
|
|
return False
|
|
except (OSError, urllib.error.URLError, ValueError):
|
|
return False
|
|
|
|
|
|
def port_available(host: str, port: int) -> bool:
|
|
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sock.bind((host, port))
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def append_jsonl(path: Path, value: Mapping[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
line = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n"
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
try:
|
|
# O_APPEND makes each individual write atomic, but a short write would
|
|
# otherwise let another writer splice a record between retry chunks.
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
data = line.encode("utf-8")
|
|
offset = 0
|
|
while offset < len(data):
|
|
written = os.write(fd, data[offset:])
|
|
if written <= 0:
|
|
raise OSError("unable to append complete JSONL record")
|
|
offset += written
|
|
os.fsync(fd)
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
|
|
|
|
def event_usage(path: Path) -> dict[str, int]:
|
|
"""Extract canonical token usage, preferring cumulative app-server totals."""
|
|
|
|
maxima: dict[str, int] = {}
|
|
app_server_totals: dict[str, int] = {}
|
|
event_count = 0
|
|
interesting = {
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"cached_input_tokens",
|
|
"cache_write_input_tokens",
|
|
"reasoning_output_tokens",
|
|
"reasoning_tokens",
|
|
"total_tokens",
|
|
}
|
|
app_server_names = {
|
|
"inputTokens": "input_tokens",
|
|
"outputTokens": "output_tokens",
|
|
"cachedInputTokens": "cached_input_tokens",
|
|
"cacheWriteInputTokens": "cache_write_input_tokens",
|
|
"reasoningOutputTokens": "reasoning_output_tokens",
|
|
"totalTokens": "total_tokens",
|
|
}
|
|
|
|
def visit(value: Any) -> None:
|
|
if isinstance(value, Mapping):
|
|
for key, child in value.items():
|
|
if key in interesting and isinstance(child, int) and not isinstance(child, bool):
|
|
maxima[key] = max(maxima.get(key, 0), child)
|
|
visit(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
visit(child)
|
|
|
|
if path.is_file():
|
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
for line in handle:
|
|
try:
|
|
value = strict_json_loads(line)
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
if not isinstance(value, Mapping):
|
|
continue
|
|
event_count += 1
|
|
message = value.get("message")
|
|
if (
|
|
isinstance(message, Mapping)
|
|
and message.get("method") == "thread/tokenUsage/updated"
|
|
):
|
|
params = message.get("params")
|
|
token_usage = params.get("tokenUsage") if isinstance(params, Mapping) else None
|
|
total = token_usage.get("total") if isinstance(token_usage, Mapping) else None
|
|
if isinstance(total, Mapping):
|
|
# App-server reports a cumulative thread total. Notifications
|
|
# may be repeated during recovery, so summing them would
|
|
# double-count usage. Keep the greatest observed cumulative
|
|
# value for each canonical category.
|
|
for source, target in app_server_names.items():
|
|
count = total.get(source)
|
|
if isinstance(count, int) and not isinstance(count, bool):
|
|
app_server_totals[target] = max(
|
|
app_server_totals.get(target, 0), count
|
|
)
|
|
visit(value)
|
|
maxima.update(app_server_totals)
|
|
maxima["event_count"] = event_count
|
|
return maxima
|
|
|
|
|
|
def bounded_text(text: str, limit: int) -> tuple[str, bool]:
|
|
limit = max(0, limit)
|
|
if len(text) <= limit:
|
|
return text, False
|
|
marker = "\n\n...[truncated]...\n\n"
|
|
if limit <= len(marker):
|
|
return marker[:limit], True
|
|
payload_limit = limit - len(marker)
|
|
head = max(1, payload_limit * 2 // 3)
|
|
tail = payload_limit - head
|
|
return text[:head] + marker + (text[-tail:] if tail else ""), True
|
|
|
|
|
|
def walk_files(root: Path) -> list[Path]:
|
|
files: list[Path] = []
|
|
for path in root.rglob("*"):
|
|
if path.is_symlink():
|
|
raise ValueError(f"symbolic link is not allowed in manifest tree: {path}")
|
|
if path.is_file():
|
|
files.append(path)
|
|
elif not path.is_dir():
|
|
raise ValueError(f"special file is not allowed in manifest tree: {path}")
|
|
return sorted(files)
|
|
|
|
|
|
def manifest_for_tree(root: Path, *, exclude: Sequence[str] = ()) -> dict[str, str]:
|
|
excluded = set(exclude)
|
|
result: dict[str, str] = {}
|
|
for path in walk_files(root):
|
|
relative = path.relative_to(root).as_posix()
|
|
if relative in excluded:
|
|
continue
|
|
result[relative] = sha256_file(path)
|
|
return result
|