feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1)

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
This commit is contained in:
mjalmoudy94
2026-10-13 23:57:09 +03:30
parent 0cd9194b64
commit a0c8473eb8
15 changed files with 2047 additions and 79 deletions
+79 -54
View File
@@ -10,6 +10,10 @@
* tampered client cannot widen them.
* 2. It shows no shell/exec affordance for the platform host. The only execution
* surface is a workspace session, which is isolated and metered.
* 3. Containers, ports and command output are *infrastructure*. In v2 only the
* platform owner may touch them (PLAN-v2 §7.7), so a non-owner sees status
* only: no create form, no session, no action buttons. The read-only routes
* stay open to org members, which is what keeps this panel useful to them.
*/
import * as React from "react";
@@ -58,7 +62,16 @@ function humanBytes(bytes: number): string {
return `${bytes} بایت`;
}
export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
export function SandboxPanel({
orgId,
projectId,
canManageInfra = false,
}: {
orgId: string;
projectId: string;
/** True only for the platform owner. Gates every mutating control. */
canManageInfra?: boolean;
}) {
const [templates, setTemplates] = React.useState<SandboxTemplate[]>([]);
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
@@ -177,41 +190,51 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
</Card>
)}
<Card>
<CardTitle>ساخت محیط ایزوله</CardTitle>
<CardHint>
کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر
دستور در دفتر مصرف ثبت می‌شود.
</CardHint>
{templates.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
) : (
<form
onSubmit={onCreateWorkspace}
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<Select
label="قالب محیط"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
options={templates.map((template) => ({
value: template.id,
label: `${template.name} — ${template.image}`,
}))}
/>
</div>
<Button type="submit" loading={creating}>
ساخت محیط
</Button>
</form>
)}
{templates.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{templates.find((t) => t.id === templateId)?.description ?? ""}
</p>
)}
</Card>
{canManageInfra ? (
<Card>
<CardTitle>ساخت محیط ایزوله</CardTitle>
<CardHint>
کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر
دستور در دفتر مصرف ثبت می‌شود.
</CardHint>
{templates.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
) : (
<form
onSubmit={onCreateWorkspace}
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<Select
label="قالب محیط"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
options={templates.map((template) => ({
value: template.id,
label: `${template.name} — ${template.image}`,
}))}
/>
</div>
<Button type="submit" loading={creating}>
ساخت محیط
</Button>
</form>
)}
{templates.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{templates.find((t) => t.id === templateId)?.description ?? ""}
</p>
)}
</Card>
) : (
<Card>
<CardTitle>محیط اجرا</CardTitle>
<CardHint>
زیرساخت (کانتینر، پورت و اجرای دستور) تنها توسط مالک سامانه مدیریت می‌شود. برای
اجرای کارها کافی است به مدیر هوش مصنوعی دستور بدهید.
</CardHint>
</Card>
)}
<Card>
<CardTitle>محیط‌های این پروژه</CardTitle>
@@ -242,23 +265,25 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
ارائه‌دهنده: <span className="ltr-inline">{workspace.provider}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant={isActive ? "ghost" : "secondary"}
onClick={() => onStartSession(workspace)}
loading={starting && !isActive}
>
{isActive ? "انتخاب‌شده" : "انتخاب و شروع نشست"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingDestroy(workspace)}
>
حذف
</Button>
</div>
{canManageInfra && (
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant={isActive ? "ghost" : "secondary"}
onClick={() => onStartSession(workspace)}
loading={starting && !isActive}
>
{isActive ? "انتخاب‌شده" : "انتخاب و شروع نشست"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingDestroy(workspace)}
>
حذف
</Button>
</div>
)}
</li>
);
})}
@@ -266,7 +291,7 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
)}
</Card>
{activeWorkspace && session && (
{canManageInfra && activeWorkspace && session && (
<SessionPanel
orgId={orgId}
session={session}