Add sandbox environment support (#4415)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The environment/runtime layer decides where agent work executes and
how the control plane reaches those runtimes.
> - Today Paperclip can run locally and over SSH, but sandboxed
execution needs a first-class environment model instead of one-off
adapter behavior.
> - We also want sandbox providers to be pluggable so the core does not
hardcode every provider implementation.
> - This branch adds the Sandbox environment path, the provider
contract, and a deterministic fake provider plugin.
> - That required synchronized changes across shared contracts, plugin
SDK surfaces, server runtime orchestration, and the UI
environment/workspace flows.
> - The result is that sandbox execution becomes a core control-plane
capability while keeping provider implementations extensible and
testable.

## What Changed

- Added sandbox runtime support to the environment execution path,
including runtime URL discovery, sandbox execution targeting,
orchestration, and heartbeat integration.
- Added plugin-provider support for sandbox environments so providers
can be supplied via plugins instead of hardcoded server logic.
- Added the fake sandbox provider plugin with deterministic behavior
suitable for local and automated testing.
- Updated shared types, validators, plugin protocol definitions, and SDK
helpers to carry sandbox provider and workspace-runtime contracts across
package boundaries.
- Updated server routes and services so companies can create sandbox
environments, select them for work, and execute work through the sandbox
runtime path.
- Updated the UI environment and workspace surfaces to expose sandbox
environment configuration and selection.
- Added test coverage for sandbox runtime behavior, provider seams,
environment route guards, orchestration, and the fake provider plugin.

## Verification

- Ran locally before the final fixture-only scrub:
  - `pnpm -r typecheck`
  - `pnpm test:run`
  - `pnpm build`
- Ran locally after the final scrub amend:
  - `pnpm vitest run server/src/__tests__/runtime-api.test.ts`
- Reviewer spot checks:
  - create a sandbox environment backed by the fake provider plugin
  - run work through that environment
- confirm sandbox provider execution does not inherit host secrets
implicitly

## Risks

- This touches shared contracts, plugin SDK plumbing, server runtime
orchestration, and UI environment/workspace flows, so regressions would
likely show up as cross-layer mismatches rather than isolated type
errors.
- Runtime URL discovery and sandbox callback selection are sensitive to
host/bind configuration; if that logic is wrong, sandbox-backed
callbacks may fail even when execution succeeds.
- The fake provider plugin is intentionally deterministic and
test-oriented; future providers may expose capability gaps that this
branch does not yet cover.

## Model Used

- OpenAI Codex coding agent on a GPT-5-class backend in the
Paperclip/Codex harness. Exact backend model ID is not exposed
in-session. Tool-assisted workflow with shell execution, file editing,
git history inspection, and local test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-04-24 12:15:53 -07:00 committed by GitHub
parent 641eb44949
commit 70679a3321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 10469 additions and 1498 deletions

View file

@ -0,0 +1,167 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanySettings } from "./CompanySettings";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockCompaniesApi = vi.hoisted(() => ({
update: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
createOpenClawInvitePrompt: vi.fn(),
getInviteOnboarding: vi.fn(),
}));
const mockAssetsApi = vi.hoisted(() => ({
uploadCompanyLogo: vi.fn(),
}));
const mockEnvironmentsApi = vi.hoisted(() => ({
list: vi.fn(),
capabilities: vi.fn(),
create: vi.fn(),
update: vi.fn(),
probe: vi.fn(),
probeConfig: vi.fn(),
archive: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
vi.mock("../api/companies", () => ({
companiesApi: mockCompaniesApi,
}));
vi.mock("../api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("../api/assets", () => ({
assetsApi: mockAssetsApi,
}));
vi.mock("../api/environments", () => ({
environmentsApi: mockEnvironmentsApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({
pushToast: mockPushToast,
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
selectedCompany: {
id: "company-1",
name: "Paperclip",
description: null,
brandColor: null,
logoUrl: null,
issuePrefix: "PAP",
},
selectedCompanyId: "company-1",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("CompanySettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableEnvironments: true,
});
mockEnvironmentsApi.list.mockResolvedValue([]);
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities(AGENT_ADAPTER_TYPES),
);
mockSecretsApi.list.mockResolvedValue([]);
mockCompaniesApi.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",
description: null,
brandColor: null,
logoUrl: null,
issuePrefix: "PAP",
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("hides sandbox creation when no run-capable sandbox provider plugins are installed", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanySettings />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const optionLabels = Array.from(container.querySelectorAll("option")).map((option) => option.textContent?.trim());
expect(optionLabels).not.toContain("Sandbox");
expect(container.textContent).not.toContain("Fake sandbox");
expect(container.textContent).not.toContain("Fake is the deterministic test provider");
await act(async () => {
root.unmount();
});
});
});