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:
@@ -34,7 +34,7 @@ export default function ProjectDetailPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: projectId } = React.use(params);
|
||||
const { activeOrganization } = useSession();
|
||||
const { activeOrganization, user } = useSession();
|
||||
|
||||
const [tab, setTab] = React.useState<Tab>("repo");
|
||||
const [project, setProject] = React.useState<Project | null>(null);
|
||||
@@ -136,7 +136,11 @@ export default function ProjectDetailPage({
|
||||
) : tab === "preview" ? (
|
||||
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
|
||||
) : (
|
||||
<SandboxPanel orgId={activeOrganization.id} projectId={project.id} />
|
||||
<SandboxPanel
|
||||
orgId={activeOrganization.id}
|
||||
projectId={project.id}
|
||||
canManageInfra={user?.is_platform_owner ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user