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.
280 lines
9.9 KiB
Python
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",
|
|
]
|