Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.
- admin_domains.py (owner-only): list every route with its owning app, attach an
extra host, detach with the host typed back as confirmation (audited), re-sync
the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
is on. A wildcard is refused in both (it would match another tenant's
subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
sync_routes. Re-sync writes and reloads only when the rendered state differs:
a reload drops every in-flight connection on the host, including live demos.
Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
because the demo host is still created through them at app-creation time.
Tests: 473 API (+58); ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-4.md
A push to `dev` now becomes a live demo, and a failed build never takes the
demo down.
- Four tables (pipeline_runs, pipeline_steps, releases, app_runtimes) and their
migration; alembic upgrade/downgrade/check all clean.
- DeliveryService implements DeliveryHooks: on_app_created provisions the demo
port + host + first build (idempotently); on_branch_pushed queues a run for
`dev` and supersedes older *queued* runs, leaving a running one alone.
- The 8-step pipeline. Blue/green is real: the replacement starts on a staging
port, is healthchecked, and only then does the route move — then the previous
container is stopped. A failure removes the replacement, releases its port and
leaves the old container serving.
- The demo port ledger is the DATABASE, not the router adapters' in-memory maps:
the API and the delivery worker are separate processes.
- Runs are claimed with a conditional UPDATE, so two workers never build the
same run.
- Live logs are batched (1 event / 500 ms, 200 lines) and redacted as a whole
tail — per-chunk redaction misses a secret split across pipe reads, and the
*stored* tail leaked until this was fixed.
- delivery worker: drain the queue, then poll remotes for pushes made outside
the engine.
- 7 routes; reads need membership, actions need projects.manage and are audited.
Tests: 415 API (+21, 1 docker-marked skipped); ruff, mypy, alembic check clean.
Report: docs/plan/reports/agent-3-phase-3.md
The platform's side of D6: git runs on the host, in a gitdir that is never
mounted into a container, so repository credentials never reach the model.
- adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing
RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at
/workspace + one linked worktree per task under work/.worktrees/<id>
(excluded via the gitdir's info/exclude, not the project's .gitignore).
Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off,
quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off,
one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH
key as a 0600 temp file with a pinned known_hosts. Everything deleted in a
finally; only a *path* ever travels through the environment.
- The bug this phase existed to find: --git-dir=<shared> does not address a
linked worktree, so `git commit` in a task worktree committed straight to
`dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and
never read the worktree's .git file, which a repository can rewrite.
- domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed
(resolves, refuses if any address is loopback/private/link-local/metadata).
Shared by the git remote test and, later, every deploy target.
- ProjectExternalGit domain model + repository; container_delivery wires the
real adapter behind GIT_WORKSPACE=local with a resolver that joins the
project, its repo and its (decrypted) credentials.
Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-2.md
The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.
- adapters/process.py: the one place the platform shells out. argv-only (no
shell), start_new_session + killpg on timeout, concurrent stdout/stderr
drains with a byte cap, explicit minimal env (never os.environ), and a
missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
--user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
--read-only with tmpfs scratch, one bind mount (the work tree, never the git
dir), labels, no published ports. Path-safe file access; secrets refused in
`docker exec -e`. Extras for later phases: start_demo_container (loopback
publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
(PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
the session panel and the action buttons from non-owners.
Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.
Report: docs/plan/reports/agent-3-phase-1.md
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users
(فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند.
در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر
وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود.
- تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت،
با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با
POST /admin/transfer-ownership و در یک تراکنش انجام میشود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت).
- تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
نام نمایشی و هویت اپلیکیشن در همهٔ سطوح کاربر-پسند از «AGY Web» به
«Smart Organization» تغییر کرد:
- UI: عنوان تب مرورگر، صفحهٔ ورود، برند و لوگوی پوستهٔ اپ (AG → SO)
- بکاند: مقدار پیشفرض app_name، نام و توضیح توزیع پایتون
- مانیفستها: package.json ریشه و چهار پکیج، pyproject.toml
- مستندات: README، PLAN، TODO و دو سند docs
شناسههای داخلی عمداً دستنخورده ماندهاند: scope پکیجهای @agy/*،
کلیدهای localStorage، نامهای logger، نام فایل پایگاهٔ داده، و اطلاعات
اتصال پایگاه داده در .env.example و infra/docker/docker-compose.yml.
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.