Files
smart-org/docs/plan/reports/agent-3-final.md
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

12 KiB
Raw Blame History

Agent 3 — Final report: Delivery & Infra (phases 1–6)

Date: 2026-10-14 · Branch: dev · Supersedes and summarises agent-3-phase-1.md … agent-3-phase-6.md.

This file is the integration hand-off: what exists, what it needs on the host, every setting and endpoint it added, the tables it owns, the change requests still open, the manual QA script, and the things deliberately not done.

The golden property, stated once because every design decision serves it: the AI never touches the host, the network, or a credential. Git runs as the platform user with a gitdir that is never mounted into a container; code runs inside hardened containers; user-supplied hosts pass an SSRF check before a byte leaves; and nothing reaches production without a human click.


1. What exists

Phase Delivered
1 DockerSandboxAdapter (hardened workspace + demo containers), process.py (the single hardened subprocess runner), orphan reaping, infra hidden from non-owners
2 LocalGitWorkspaceAdapter — the platform's own git: per-project gitdir, worktrees per task, per-repo lock, hooks disabled, credentials only ever as a path
3 Live CI/CD: pipeline runs/steps/releases/runtimes, build containers, blue/green demo deploy, live batched logs, remote polling
4 Owner domain console: list/attach/detach/resync routes, custom FQDNs behind a setting, MCP gateway system route
5 Production deploy: asyncssh + aioftp adapters with host-key TOFU, atomic symlink switch, FTP backup-first, production_deploys ledger
6 The Application page (four tabs + live build console + production panel), /admin/domains, /admin/sandbox, /projects* → /app redirect, typed client + contracts

Phase 5's adapters are lazy imports. asyncssh and aioftp are dependencies, but importing them costs real start-up time and neither is needed unless somebody configures a deploy target. deploy_target.py therefore resolves them inside _connect(), which is also what keeps mypy honest about an optional import.


2. Host requirements

Requirement Why Notes
docker or podman on PATH (SANDBOX_DOCKER_BIN) the only sanctioned way tenant code runs the flags used are the common subset; the workspace/demo/build containers are --read-only, non-root, --cap-drop ALL, no-new-privileges, cpu/mem/pids capped
git ≥ 2.20 on PATH the platform runs git itself (D6) worktrees + --git-dir/--work-tree
caddy on PATH and import /etc/caddy/agy-preview/*.caddy in the Caddyfile (DOMAIN_ROUTER=caddy) preview + custom domains the adapter refuses to run if the import line is missing — silently writing a file Caddy never reads would report a success that is false
Caddy reload permission saving a route reloads the live proxy caddy reload needs to signal the caddy service; on a systemd host that is a polkit/sudoers entry, not root. The command used is caddy reload --config <file>, so granting the service user passwordless caddy reload is sufficient.
Writable, disk-rich dirs under SANDBOX_WORKSPACES_ROOT / ARTIFACTS_ROOT work trees, per-run copies, artifacts, tarballs default ./.data/... (gitignored). Per-run copies mean disk grows with project size × runs — see Known limitations
ftps targets explicit TLS a plain ftp target is accepted and documented as unencrypted
DEPLOY_IT=1 / SANDBOX_DOCKER_IT=1 integration tests both skip cleanly otherwise

Nothing else runs on the host. There is no shell=True anywhere in the workstream; app/infrastructure/adapters/process.py is the only module that calls asyncio.create_subprocess_exec.


3. Settings (agent-3 block, app/core/config.py)

Setting Default Meaning
SANDBOX_DOCKER_BIN docker container runtime CLI (podman works)
SANDBOX_WORKSPACES_ROOT ./.data/workspaces work trees, gitdirs, run dirs
SANDBOX_DEFAULT_CPUS / _MEMORY_MB 1.0 / 1024 container caps
DEMO_PORT_START / DEMO_PORT_END 8100 / 8999 demo port pool (inside the managed range)
ARTIFACTS_ROOT ./.data/artifacts release tarballs, FTP backups
DELIVERY_POLL_SECONDS 60 how often remotes are polled for outside pushes
DEPLOY_ALLOW_PRIVATE_HOSTS false escape hatch for a private-network target; off by default
GIT_WORKSPACE fake local enables the real host git adapter
ALLOW_CUSTOM_DOMAINS false owner-supplied external FQDNs
GIT_ALLOW_FILE_PROTOCOL false protocol.file.allow=never; true only to test against a local bare repo
SANDBOX_PROVIDER / DOMAIN_ROUTER / GIT_PROVIDER mock / mock / mock shared with Phase 1; docker/caddy are the real values

.env.example carries the same block, and the docker / deploy_it pytest markers are registered in pyproject.toml.


4. Endpoints added or changed

All paths are under /api.

Delivery — /organizations/{org_id}/delivery (tenant-scoped)

Method Path Role Notes
GET /runs member pipeline run list
GET /runs/{run_id} member run + its 8 steps + log tails
POST /runs org admin/owner manual re-run of the dev head
POST /runs/{run_id}/cancel org admin/owner
GET /releases member release history
POST /releases/rollback org admin/owner demo rollback, slug-confirmed
GET /runtime member demo URL, live release, health
GET /app member app summary for the page header
GET /git member repo URL + dev head + last run
GET /production member target summary + deploy history
POST /production/test org admin/owner connection test + host-key TOFU
POST /production/deploy org admin/owner human-triggered, confirm: "<org-slug>", one at a time
POST /production/rollback org admin/owner

Domains — /admin/domains (platform owner)

Method Path Notes
GET `` every route, app routes and system routes
POST `` attach a host (label, or a custom FQDN when the setting is on)
DELETE /{domain_id} detach, host typed back as confirmation
POST /sync idempotent re-sync from the database
GET /suggested MCP gateway suggestion

Sandbox — /organizations/{org_id}/sandbox/* (changed in Phase 1)

Mutating routes (create/start/stop workspace, run command, read/write file, snapshot, preview URL, set limits) became platform-owner only. Read-only status (templates, effective limits, workspace list, command log) stayed member-open.

Sandbox console — /admin/sandbox (new, platform owner)

GET /admin/sandbox/containers and POST /admin/sandbox/containers/{id}/stop, mounted on a separate admin_router inside sandbox.py so the tenant router stays org-scoped.

Projects — /organizations/{org_id}/projects/{pid}/preview/* (changed in Phase 4)

All four endpoints (ports, domains create/list/delete) became platform-owner only.


5. Tables (all in models_delivery.py)

pipeline_runs, pipeline_steps, releases, app_runtimes, production_deploys, system_routes. Migration a3b4c5d6e7f3_delivery_infra (down_revision = "a0f0e1d2c3b4").


6. Manual QA script

Run against a real docker + caddy host with SANDBOX_PROVIDER=docker GIT_WORKSPACE=local DOMAIN_ROUTER=caddy.

  1. Wizard → app: create an org, complete the wizard with git_source=platform. Expect a demo host <org-slug>.<root> claimed and an app_runtimes row with a pending status.
  2. First build: push a commit to dev. Watch org:<id> receive pipeline.queued → 8 × pipeline.step → pipeline.status. Expect checkout, setup, build, test, package, deploy_demo, healthcheck, route, a release marked live, and the demo container on the new port.
  3. Open the demo URL — the healthcheck step already proved the app answers.
  4. Red build: push a commit that fails the test command. Expect failed with a Persian summary and the previous demo still serving on the old port.
  5. Rollback: from the app page, roll the demo back. The release flips live, the container is replaced from the artifact, release.live fires.
  6. Outside push: with git_source=external, push to the remote from another machine. Within DELIVERY_POLL_SECONDS a run appears with trigger=poll.
  7. Production, SSH: POST /production/test against a local sshd container, then POST /production/deploy with confirm: "<org-slug>". Expect a release dir, the atomic current symlink switch, the post-deploy hook, and pruning to the newest 5. Change the server's host key → the next test must fail ("کلید میزبان تغییر کرده است").
  8. Production, FTP: the same against a pure-ftpd container; expect a ftp-backup-<ts>.tar.gz and, on rollback, that archive re-uploaded.
  9. Domains: as the owner attach a second label; re-sync twice and check the second call reports up_to_date: true with no Caddy reload. Enable the MCP system route; mcp.<root> must answer through the gateway port.
  10. SSRF: attempt a production deploy to 127.0.0.1, 10.0.0.1 and 169.254.169.254. All three must be refused before any connection.
  11. Tenant isolation: a member of org B gets 404 on every org-A delivery and admin-domains path; a non-admin member gets 403 on every mutation.
  12. Secret hygiene: set a deploy target, run a deploy, then grep the logs, the pipeline_steps.log_tail and the realtime events for the password. Nothing.

7. CHANGE REQUESTS (still open, for integration)

  1. The AI workforce (Agent 2) needs the two delivery hooks wired. container_delivery.build_delivery_hooks(session) returns the real service; the wizard (Agent 1) should call on_app_created and the engine should call on_branch_pushed after a merge+push. Until then nothing queues runs except a manual POST /delivery/runs.
  2. The app profile is Agent 1's. The Application page reads setup/build/test/start commands, app_port and healthcheck_path from it. The Git tab shows the dev head; the app-type badges in the header wait for Phase A1-2.
  3. The web needs the caller's org role to gate the production buttons client-side. MeResponse.organizations already carries OrganizationWithRole. Today the server is the boundary and the UI renders optimistically.
  4. Reserved-label enforcement now lives in two places and they must stay in step. The tenant path is guarded in ProjectService.create_preview_domain (label in PROTECTED_HOST_LABELS → PermissionDeniedError); the owner path is guarded inside rules.validate_extra_host. A future third entry point that builds a host should call one of them, not re-derive the rule.

8. Known limitations (deliberate, documented)

  • Build containers cannot write into the AI's work tree. Each run gets a copy of the tree under <root>/…/runs/<run_id>/ (.git/node_modules excluded). RepoWorkspacePort is frozen and has no checkout primitive, and a build writing dist/ into work/ would show up in the next task's diff as if an employee had changed it.
  • Disk per run. Nothing prunes runs/, artifacts/ or ftp-backup-* yet.
  • The healthcheck is a plain GET with 20 × 3 s retries, no readiness protocol, no auth on the probe path. Any 2xx/3xx counts as healthy.
  • snapshot does not capture the work tree (bind mount) — image state only.
  • FTP has no atomic switch and no host key. The deploy is backup-first so there is always something to undo, but the window is not zero, and the plain ftp kind is unencrypted by definition.
  • No container-level egress policy for build containers beyond the default bridge. A hermetic build would need an explicit deny and a package mirror.
  • routes_fingerprint compares host:port pairs. Changing a label on a demo host is a remove+add, which the digest does catch; a change that keeps both host and port but edits the site block body would not.
  • The AI never commits, merges, pushes or inspects git — that is the point (D6), but it also means Agent 2's engine must go through RepoWorkspacePort for all of it, and a git failure surfaces to the customer as a Persian message rather than a git exit code.