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.
12 KiB
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.
- Wizard → app: create an org, complete the wizard with
git_source=platform. Expect a demo host<org-slug>.<root>claimed and anapp_runtimesrow with apendingstatus. - First build: push a commit to
dev. Watchorg:<id>receivepipeline.queued→ 8 ×pipeline.step→pipeline.status. Expectcheckout,setup,build,test,package,deploy_demo,healthcheck,route, a release marked live, and the demo container on the new port. - Open the demo URL — the healthcheck step already proved the app answers.
- Red build: push a commit that fails the test command. Expect
failedwith a Persian summary and the previous demo still serving on the old port. - Rollback: from the app page, roll the demo back. The release flips live,
the container is replaced from the artifact,
release.livefires. - Outside push: with
git_source=external, push to the remote from another machine. WithinDELIVERY_POLL_SECONDSa run appears withtrigger=poll. - Production, SSH:
POST /production/testagainst a localsshdcontainer, thenPOST /production/deploywithconfirm: "<org-slug>". Expect a release dir, the atomiccurrentsymlink switch, the post-deploy hook, and pruning to the newest 5. Change the server's host key → the next test must fail ("کلید میزبان تغییر کرده است"). - Production, FTP: the same against a
pure-ftpdcontainer; expect aftp-backup-<ts>.tar.gzand, on rollback, that archive re-uploaded. - Domains: as the owner attach a second label; re-sync twice and check the
second call reports
up_to_date: truewith no Caddy reload. Enable the MCP system route;mcp.<root>must answer through the gateway port. - SSRF: attempt a production deploy to
127.0.0.1,10.0.0.1and169.254.169.254. All three must be refused before any connection. - 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.
- Secret hygiene: set a deploy target, run a deploy, then grep the logs, the
pipeline_steps.log_tailand the realtime events for the password. Nothing.
7. CHANGE REQUESTS (still open, for integration)
- 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 callon_app_createdand the engine should callon_branch_pushedafter a merge+push. Until then nothing queues runs except a manualPOST /delivery/runs. - The app profile is Agent 1's. The Application page reads
setup/build/test/start commands,app_portandhealthcheck_pathfrom it. The Git tab shows thedevhead; the app-type badges in the header wait for Phase A1-2. - The web needs the caller's org role to gate the production buttons
client-side.
MeResponse.organizationsalready carriesOrganizationWithRole. Today the server is the boundary and the UI renders optimistically. - 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 insiderules.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_modulesexcluded).RepoWorkspacePortis frozen and has no checkout primitive, and a build writingdist/intowork/would show up in the next task's diff as if an employee had changed it. - Disk per run. Nothing prunes
runs/,artifacts/orftp-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.
snapshotdoes 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
ftpkind 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_fingerprintcompares 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
RepoWorkspacePortfor all of it, and a git failure surfaces to the customer as a Persian message rather than a git exit code.