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.
This commit is contained in:
Agent 3
2026-10-14 12:07:10 +03:30
parent 5095a29df9
commit 22374f3e1c
5 changed files with 252 additions and 3 deletions
@@ -32,6 +32,7 @@ from app.core.crypto import decrypt, encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, slugify
from app.domain.platform.rules import (
PROTECTED_HOST_LABELS,
assert_not_protected_port,
assert_port_allocatable,
build_host,
@@ -195,6 +196,13 @@ class ProjectService:
port: int | None = None,
) -> dict:
label = validate_subdomain_label(requested_subdomain)
# ``git``/``mcp``/``api``/``panel``… are platform services. A tenant host
# must never be able to shadow one on the same root domain — the rules
# module defines the reserved set (see PROTECTED_HOST_LABELS).
if label in PROTECTED_HOST_LABELS:
raise PermissionDeniedError(
f"نام «{label}» برای سرویس‌های سامانه رزرو شده است."
)
host = build_host(label, settings.preview_root_domain)
if await self.domains.get_by_host(host) is not None:
@@ -60,6 +60,11 @@ _TERM_GRACE_SECONDS = 5.0
#: 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)."""
@@ -184,7 +189,7 @@ async def _terminate(proc: asyncio.subprocess.Process) -> None:
return
except TimeoutError:
pass
_signal_group(signal.SIGKILL)
_signal_group(_SIGKILL)
with contextlib.suppress(Exception):
await proc.wait()