Files
smart-org/apps/api/app/infrastructure/adapters/process.py
T
Agent 3 22374f3e1c feat(delivery): final report + reserved-label guard + Windows test fixes (v2 phase A3-final)
Gap-closing on top of the merged A3-3..A3-6 work:

- ProjectService.create_preview_domain now refuses a label that is a reserved
  platform name (git, mcp, api, panel, ...) via PROTECTED_HOST_LABELS. The rules
  set existed but only the owner-facing validate_extra_host consulted it, so the
  tenant path could reserve one.
- process.py: signal.SIGKILL is resolved through getattr with a SIGTERM
  fallback, so the module imports and type-checks on Windows where SIGKILL does
  not exist.
- Three more Windows test repairs: a git-created .git must be unlinked and
  recreated rather than overwritten in place; POSIX-only chmod and symlink
  assertions are gated on os.name (the security property they assert is checked
  everywhere, the platform-specific mechanics only where they apply).
- docs/plan/reports/agent-3-final.md: the integration hand-off — host
  requirements, every setting, every endpoint added or changed, the tables, the
  open CHANGE REQUESTS, a 12-step manual QA script and the known limitations.

Checks: ruff clean, mypy clean (136 files), 521 API tests pass, 83 web tests
pass, tsc clean, next lint clean.
2026-10-14 12:07:10 +03:30

280 lines
9.9 KiB
Python

"""The one place the platform shells out to a host binary.
Nothing else in the codebase may call ``subprocess`` or ``os.system``. Every
adapter that needs an external binary (docker/podman, git, caddy reload) goes
through :func:`run_process`, which exists to make four failure modes impossible
rather than merely unlikely:
**Shell injection.** The command is an ``argv`` sequence handed straight to
``asyncio.create_subprocess_exec``. There is no ``shell=True`` path and no
string interpolation into a shell, so a task id or a branch name can never
become a second command.
**Orphaned children.** The child starts in its own session
(``start_new_session=True``), so a timeout kills the whole *process group*. A
``docker build`` that spawns helpers, or a ``git`` that runs a credential
helper, dies with its parent instead of lingering as a stray process holding a
lock.
**Pipe deadlock.** stdout and stderr are drained concurrently by two tasks.
Reading one to completion before touching the other is the classic 64 KB
deadlock: the child blocks writing stderr, we block reading stdout, and both
wait forever. ``communicate()`` is not used because we want a byte cap and a
streaming callback.
**Secret inheritance.** The child gets an explicit, minimal environment built
from :func:`base_env`, never ``os.environ`` wholesale. The platform process
holds the JWT secret, the database DSN and every provider key; a subprocess that
inherits them can leak them through a stack trace, a build log or a core dump.
Output is capped in bytes while streaming, so a runaway build cannot exhaust
memory. The cap is applied *after* the callback sees the chunk, so live logs
still show everything that arrived.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import signal
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
#: Environment variables a child may inherit from the platform process. Anything
#: not listed is dropped. Deliberately tiny.
_BASE_ENV_KEYS: tuple[str, ...] = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR")
#: Fallback PATH when the parent has none (systemd units often do not).
_FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
#: Read size per pipe chunk. 64 KiB is the pipe buffer size, so this drains
#: promptly without spinning.
_CHUNK_BYTES = 65_536
#: How long a child gets to die politely after SIGTERM before SIGKILL.
_TERM_GRACE_SECONDS = 5.0
#: An argv element longer than this is almost certainly a mistake (a base64 blob
#: pasted into a command line). Refused rather than silently truncated.
MAX_ARGV_ELEMENT_CHARS = 100_000
#: ``SIGKILL`` does not exist on Windows (it has no equivalent), so it is looked
#: up rather than referenced directly. POSIX is the only *supported* target, but
#: the module must still import — and type-check — on a developer's Windows box.
_SIGKILL: int = getattr(signal, "SIGKILL", signal.SIGTERM)
class ProcessError(RuntimeError):
"""A host command could not be started at all (binary missing, not executable)."""
@dataclass(slots=True)
class ProcessResult:
"""Outcome of one host command.
``argv`` is retained so a test can assert on the *exact* argument vector —
which is how the hardening flags and the "no credential in argv" rule are
verified.
"""
argv: tuple[str, ...]
exit_code: int
stdout: str = ""
stderr: str = ""
duration_ms: int = 0
timed_out: bool = False
truncated: bool = False
#: True when the binary itself could not be executed (ENOENT/EACCES).
spawn_failed: bool = False
spawn_error: str = ""
@property
def ok(self) -> bool:
return self.exit_code == 0 and not self.timed_out and not self.spawn_failed
@dataclass(slots=True)
class _DrainResult:
data: bytes = b""
truncated: bool = False
lines: list[str] = field(default_factory=list)
def base_env(extra: Mapping[str, str] | None = None) -> dict[str, str]:
"""Minimal child environment: a few safe keys plus explicit overrides.
Callers add what they need (``HOME`` for git, ``GIT_CONFIG_NOSYSTEM=1``) and
nothing else is inherited.
"""
env = {key: os.environ[key] for key in _BASE_ENV_KEYS if key in os.environ}
env.setdefault("PATH", _FALLBACK_PATH)
if extra:
env.update({str(k): str(v) for k, v in extra.items()})
return env
def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]:
if not argv:
raise ValueError("argv must not be empty")
cleaned: list[str] = []
for index, element in enumerate(argv):
if not isinstance(element, str):
raise TypeError(f"argv[{index}] must be str, got {type(element).__name__}")
if "\x00" in element:
raise ValueError(f"argv[{index}] contains a NUL byte")
if len(element) > MAX_ARGV_ELEMENT_CHARS:
raise ValueError(
f"argv[{index}] is {len(element)} chars; "
f"the limit is {MAX_ARGV_ELEMENT_CHARS}"
)
cleaned.append(element)
return tuple(cleaned)
async def _drain(
stream: asyncio.StreamReader | None,
*,
cap_bytes: int,
on_output: Callable[[str], Awaitable[None]] | None,
) -> _DrainResult:
"""Read a pipe to EOF, keeping at most ``cap_bytes`` and streaming every chunk."""
result = _DrainResult()
if stream is None:
return result
buffer = bytearray()
truncated = False
while True:
chunk = await stream.read(_CHUNK_BYTES)
if not chunk:
break
if len(buffer) < cap_bytes:
remaining = cap_bytes - len(buffer)
buffer.extend(chunk[:remaining])
if len(chunk) > remaining:
truncated = True
else:
truncated = True
if on_output is not None:
# The callback sees the *raw* chunk even past the cap: a live log
# should not stop scrolling just because we stopped storing.
with contextlib.suppress(Exception):
await on_output(chunk.decode("utf-8", "replace"))
result.data = bytes(buffer)
result.truncated = truncated
return result
async def _terminate(proc: asyncio.subprocess.Process) -> None:
"""SIGTERM the child's process group, then SIGKILL if it will not go."""
if proc.returncode is not None:
return
def _signal_group(sig: int) -> None:
# The child is a session leader (``start_new_session=True``), so its pid
# *is* its process-group id. Fall back to the pid on platforms without
# process groups.
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
if hasattr(os, "killpg"):
os.killpg(proc.pid, sig)
else: # pragma: no cover - POSIX is the only supported target
proc.send_signal(sig)
_signal_group(signal.SIGTERM)
try:
await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_SECONDS)
return
except TimeoutError:
pass
_signal_group(_SIGKILL)
with contextlib.suppress(Exception):
await proc.wait()
async def run_process(
argv: Sequence[str],
*,
cwd: str | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: float = 120.0,
max_output_bytes: int = 200_000,
on_output: Callable[[str], Awaitable[None]] | None = None,
stdin_data: bytes | None = None,
) -> ProcessResult:
"""Run ``argv`` and return its bounded, timed result.
Never raises for a non-zero exit or a timeout — those are ordinary outcomes
the caller inspects. It *does* raise :class:`ProcessError` when the binary
cannot be started, because that is a deployment fault, not a command result.
``stdin_data`` is written and closed immediately; the platform never hands a
child an interactive terminal.
"""
cleaned = _validate_argv(argv)
started = time.perf_counter()
try:
proc = await asyncio.create_subprocess_exec(
*cleaned,
cwd=cwd,
env=base_env(env),
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc:
return ProcessResult(
argv=cleaned,
exit_code=127,
stderr=str(exc),
duration_ms=int((time.perf_counter() - started) * 1000),
spawn_failed=True,
spawn_error=type(exc).__name__,
)
if stdin_data is not None and proc.stdin is not None:
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
proc.stdin.write(stdin_data)
await proc.stdin.drain()
with contextlib.suppress(Exception):
proc.stdin.close()
stdout_task = asyncio.create_task(
_drain(proc.stdout, cap_bytes=max_output_bytes, on_output=on_output)
)
stderr_task = asyncio.create_task(
_drain(proc.stderr, cap_bytes=max_output_bytes, on_output=None)
)
timed_out = False
try:
await asyncio.wait_for(proc.wait(), timeout=max(1.0, float(timeout_seconds)))
except TimeoutError:
timed_out = True
await _terminate(proc)
stdout_result, stderr_result = await asyncio.gather(stdout_task, stderr_task)
return ProcessResult(
argv=cleaned,
exit_code=proc.returncode if proc.returncode is not None else -1,
stdout=stdout_result.data.decode("utf-8", "replace"),
stderr=stderr_result.data.decode("utf-8", "replace"),
duration_ms=int((time.perf_counter() - started) * 1000),
timed_out=timed_out,
truncated=stdout_result.truncated or stderr_result.truncated,
)
__all__ = [
"MAX_ARGV_ELEMENT_CHARS",
"ProcessError",
"ProcessResult",
"base_env",
"run_process",
]