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
@@ -0,0 +1,114 @@
/**
* §v2-A3.1.4 — infrastructure is the platform owner's alone.
*
* The sandbox panel used to expose a command box, a file editor, a snapshot
* button and a preview-port picker to anyone in the organization. In v2 a
* customer gives orders to the AI and never touches a container, so the same
* component must render *status only* for a non-owner — and the backend now
* answers 403 to the routes the hidden controls used to call.
*
* The assertion that matters is a negative one: the mutating controls must not
* be in the document at all. A merely disabled button would still be a
* discoverable affordance and would still be reachable by a script.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
sandboxTemplates: vi.fn(),
sandboxWorkspaces: vi.fn(),
sandboxLimits: vi.fn(),
sandboxCommands: vi.fn(),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError, api: mocks };
});
vi.mock("@/lib/realtime", () => ({ useLiveChannel: () => {} }));
import { SandboxPanel } from "@/components/sandbox-panel";
const TEMPLATE = {
id: "tpl-1",
name: "پایتون",
provider: "docker",
image: "python:3.13-bookworm",
description: "قالب پایتون",
default_cpu: 1,
default_memory_mb: 1024,
default_disk_mb: 2048,
};
const LIMITS = {
cpu: 1,
memory_mb: 1024,
disk_mb: 2048,
timeout_seconds: 120,
max_output_bytes: 200000,
network_policy: "deny_all",
};
const WORKSPACE = {
id: "ws-1",
project_id: "proj-1",
template_id: "tpl-1",
provider: "docker",
status: "ready",
created_at: new Date().toISOString(),
};
describe("SandboxPanel — owner-only infrastructure", () => {
beforeEach(() => {
mocks.sandboxTemplates.mockResolvedValue([TEMPLATE]);
mocks.sandboxWorkspaces.mockResolvedValue([WORKSPACE]);
mocks.sandboxLimits.mockResolvedValue(LIMITS);
mocks.sandboxCommands.mockResolvedValue([]);
});
it("shows an org member the status and nothing else", async () => {
render(<SandboxPanel orgId="org-1" projectId="proj-1" canManageInfra={false} />);
// Status is still visible: the effective limits and the workspace list.
await waitFor(() => expect(screen.getByText("محدودیت‌های مؤثر")).toBeInTheDocument());
expect(screen.getByText("محیط‌های این پروژه")).toBeInTheDocument();
expect(screen.getByText("ws-1")).toBeInTheDocument();
// ...and the mutating affordances are absent, not merely disabled.
expect(screen.queryByText("ساخت محیط ایزوله")).not.toBeInTheDocument();
expect(screen.queryByText("ساخت محیط")).not.toBeInTheDocument();
expect(screen.queryByText("انتخاب و شروع نشست")).not.toBeInTheDocument();
expect(screen.queryByText("حذف")).not.toBeInTheDocument();
expect(screen.queryByText("نشست فعال")).not.toBeInTheDocument();
// The replacement card explains where the capability went.
expect(screen.getByText("محیط اجرا")).toBeInTheDocument();
});
it("gives the platform owner the full control surface", async () => {
render(<SandboxPanel orgId="org-1" projectId="proj-1" canManageInfra />);
await waitFor(() => expect(screen.getByText("ساخت محیط ایزوله")).toBeInTheDocument());
expect(screen.getByText("ساخت محیط")).toBeInTheDocument();
expect(screen.getByText("انتخاب و شروع نشست")).toBeInTheDocument();
expect(screen.getByText("حذف")).toBeInTheDocument();
expect(screen.queryByText("محیط اجرا")).not.toBeInTheDocument();
});
it("defaults to the restricted view when the flag is omitted", async () => {
// A call site that forgets the prop must fail *closed*.
render(<SandboxPanel orgId="org-1" projectId="proj-1" />);
await waitFor(() => expect(screen.getByText("محیط‌های این پروژه")).toBeInTheDocument());
expect(screen.queryByText("ساخت محیط")).not.toBeInTheDocument();
});
});