feat: add experimental Boat configuration and computer viewer surfaces

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 08:30:54 -05:00
1 parent 1dec4c13d1
commit 9525aa80e5
19 files changed
+500 -18

No files matched your search

@@ -27,6 +27,7 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js";
import {
materializeNativeRuntimeSkills,
prepareIsolatedCodexHome,
releaseMaterializedNativeRuntimeSkills,
} from "./runtime-context-materializer.js";
const roots: string[] = [];
@@ -93,6 +94,19 @@ function context(
}
describe("runtime context materialization", () => {
it("installs only the trusted environment computer binding and removes it on refresh without that capability", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-computer-context-"));
roots.push(root);
const codexHome = join(root, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome,
computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] } });
const config = await readFile(join(codexHome, "config.toml"), "utf8");
expect(config).toContain("[mcp_servers.paperclip_computer]");
expect(config).toContain('command = "/opt/ascii/cua-driver/cua-driver"');
await releaseMaterializedNativeRuntimeSkills(join(codexHome, "skills"));
await prepareIsolatedCodexHome({ context: null, codexHome });
expect(await readFile(join(codexHome, "config.toml"), "utf8")).not.toContain("paperclip_computer");
});
it("restores the legacy working-copy contract without changing its digest or adding new fields", () => {
const old = context("/skills", "/instructions");
old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" };
@@ -335,6 +335,8 @@ export async function prepareIsolatedCodexHome(input: {
codexHome: string;
sourceCodexHome?: string | null;
nativeMcp?: NativeMcpLaunchBinding | null;
/** Trusted execution-environment capability, never inherited from host config. */
computerTool?: { command: string; args: readonly string[] } | null;
apiKey?: string | null;
}): Promise<void> {
await materializeNativeRuntimeSkills(
@@ -365,6 +367,13 @@ export async function prepareIsolatedCodexHome(input: {
"",
]
: []),
...(input.computerTool ? [
"[mcp_servers.paperclip_computer]",
`command = ${JSON.stringify(input.computerTool.command)}`,
`args = ${JSON.stringify(input.computerTool.args)}`,
'default_tools_approval_mode = "approve"',
"",
] : []),
].join("\n"), { mode: 0o600 });
const targetAuth = join(input.codexHome, "auth.json");
@@ -1205,6 +1205,8 @@ export interface CapabilityRunnerdProcessEvidence {
}
export interface CapabilityRunnerdCodexTransportOptions {
/** Server-authorized local computer MCP on the selected execution environment. */
computerTool?: { command: string; args: readonly string[] } | null;
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
opencodePermissionMode?: NativeOpenCodePermissionMode;
acpxAgent?: QualifiedAcpxAgent;
@@ -4731,6 +4733,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.options.environment?.CODEX_API_KEY ??
this.options.environment?.OPENAI_API_KEY,
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
computerTool: this.options.computerTool,
});
}
const opencodeProxyPath =
@@ -5632,6 +5635,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.options.environment?.CODEX_API_KEY ??
this.options.environment?.OPENAI_API_KEY,
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
computerTool: this.options.computerTool,
});
}
const adoptedRunner = this.options.adoptExistingRunner;
+2
View File
@@ -2869,3 +2869,5 @@ export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./externa
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js";
export type { ComputerViewer, TaskComputer } from "./types/computer-view.js";
@@ -0,0 +1,11 @@
/** Browser credentials are ephemeral: never persist this object in task state. */
export interface ComputerViewer {
viewerUrl: string;
expiresAt: string;
owner: { computerId: string; ownerId: string; generation: number };
}
export interface TaskComputer {
environmentId: string;
name: string;
}
+1
View File
@@ -1101,3 +1101,4 @@ export type { AgentInstructionCandidate } from "./agent.js";
export * from "./skill-source.js";
export * from "./agent-lifecycle.js";
export type { ComputerViewer, TaskComputer } from "./computer-view.js";
+2
View File
@@ -10,6 +10,7 @@ import { cloudWarmStandbyMiddleware } from "./middleware/cloud-warm-standby.js";
import type { CloudWarmStandby } from "./services/cloud-warm-standby.js";
import { browserUseRoutes } from "./routes/browser-use.js";
import { registerBrowserUseCleanup } from "./services/browser-use-work.js";
import { computerRoutes } from "./routes/computers.js";
import { browserUseService } from "./services/browser-use.js";
import { slackToolRoutes } from "./routes/slack-tools.js";
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
@@ -998,6 +999,7 @@ export async function createApp(
{ cancelWorkForScope: heartbeatService(db, { pluginWorkerManager: workerManager }).cancelBudgetScopeWork },
(session, run) => toolGateway.browserUseSessionAuthorized({ ...session, runId: run.heartbeatRunId, invocationId: run.invocationId }));
api.use(browserUseRoutes(db, browserUse));
api.use(computerRoutes(db));
api.use(toolGatewayRoutes(db, toolGateway));
api.use(
pluginRoutes(
+2
View File
@@ -21,6 +21,7 @@ import { deliverExecutionStatuses } from "./services/execution-status-delivery.j
import { deliverReconciledExecutions, settleUnrecoverableExecutions } from "./services/execution-recovery-resolution.js";
import { reconcileSafeNativeReplacements } from "./services/native-runtime/native-safe-replacement.js";
import { reconcileAbandonedExecutionControl } from "./services/execution-control-reconciliation.js";
import { computerService } from "./modules/computers/index.js";
import { EXECUTION_RECONCILIATION_INTERVAL_MS } from "./services/execution-control-deadline.js";
import { connectionIntentDeliveryService } from "./services/connection-intent-delivery.js";
import { existsSync, readFileSync, rmSync } from "node:fs";
@@ -1184,6 +1185,7 @@ async function startServerWithDatabaseTeardown(
};
const executionControlSweepsInFlight = new Set<string>();
const executionControlSweeps = [
["computers", () => computerService(db).reconcile()],
["finalization", () => reconcileAbandonedExecutionControl(db)],
["replacement", () => heartbeat ? reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession: run => verifyStoppedNativeSessionForReplacement(db, run) }) : undefined],
["reconciliation_delivery", () => heartbeat ? deliverReconciledExecutions(db, heartbeat.wakeup) : undefined],
+106
View File
@@ -0,0 +1,106 @@
import { Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { agents, environmentLeases, environments, issues, type Db } from "@paperclipai/db";
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import { computerService, ComputerError } from "../modules/computers/index.js";
import { computerOwnerFromLease } from "../services/computer-environment-driver.js";
import { environmentService } from "../services/environments.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { logActivity } from "../services/activity-log.js";
import { conflict, notFound, unprocessable } from "../errors.js";
import { assertBoard, getActorInfo, getAccessibleResource } from "./authz.js";
const ownerSchema = z.object({ computerId: z.string().uuid(), ownerId: z.string().uuid(), generation: z.number().int().positive() }).strict();
const scopeSchema = z.object({ environmentId: z.string().uuid() });
const presenceSchema = scopeSchema.extend({ owner: ownerSchema }).strict();
export function computerRoutes(db: Db, computers = computerService(db)) {
const router = Router();
const settings = instanceSettingsService(db);
const envs = environmentService(db);
router.use("/issues/:issueId/computer", async (req, res, next) => {
assertBoard(req);
if (!z.string().uuid().safeParse(req.params.issueId).success) throw notFound("Task not found");
const [row] = await db.select().from(issues).where(eq(issues.id, req.params.issueId)).limit(1);
const issue = await getAccessibleResource(req, res, row, "Task not found");
if (!issue) return;
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
if (!(await settings.getExperimental()).enableBoatEnvironments && !req.path.endsWith("/disconnect")) {
if (req.method === "GET") { res.json(null); return; }
throw unprocessable("Boat environments are disabled.");
}
const [lease] = await db.select().from(environmentLeases)
.where(and(eq(environmentLeases.companyId, issue.companyId), eq(environmentLeases.issueId, issue.id), eq(environmentLeases.provider, "boat")))
.orderBy(desc(environmentLeases.updatedAt)).limit(1);
const [agent] = issue.assigneeAgentId ? await db.select().from(agents)
.where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId))).limit(1) : [];
const environmentId = lease?.environmentId ?? agent?.defaultEnvironmentId ?? (await settings.getGeneral()).defaultEnvironmentId;
const [environment] = environmentId ? await db.select().from(environments).where(eq(environments.id, environmentId)).limit(1) : [];
if (!environment || environment.driver !== "computer" || environment.metadata?.computerCompanyId !== issue.companyId || environment.status !== "active") {
if (req.method === "GET") { res.json(null); return; }
throw notFound("Computer not available for this task");
}
res.locals.computer = { environment, issue, lease, agent };
next();
});
router.get("/issues/:issueId/computer", async (_req, res) => {
const { environment } = res.locals.computer;
res.json({ environmentId: environment.id, name: environment.name });
});
router.post("/issues/:issueId/computer/connect", async (req, res) => {
const input = scopeSchema.strict().parse(req.body);
const { environment, issue, agent } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const actor = getActorInfo(req);
const viewer = await computers.connect({ companyId: issue.companyId, environmentId: environment.id,
userId: actor.actorId, idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(environment.config.runnerIdleTimeoutMs ?? agent?.adapterConfig?.idleTimeoutMs) });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.connected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } });
res.json(viewer);
});
router.post("/issues/:issueId/computer/presence", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { environment, issue } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
res.json(await computers.renewViewer({ companyId: issue.companyId, environmentId: environment.id,
owner: input.owner, userId: getActorInfo(req).actorId }));
});
router.post("/issues/:issueId/computer/disconnect", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { environment, issue } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const actor = getActorInfo(req);
await computers.disconnectViewer({ companyId: issue.companyId, environmentId: environment.id,
owner: input.owner, userId: actor.actorId });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.disconnected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } });
res.status(204).end();
});
router.post("/issues/:issueId/computer/preview", async (req, res) => {
const input = scopeSchema.extend({ port: z.number().int().min(1024).max(65535) }).strict().parse(req.body);
const { environment, issue, lease } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const current = lease ? await envs.getLeaseById(lease.id) : null;
if (!current) throw conflict("Start a dev server in this task before opening its preview.");
const result = await computers.preview({ companyId: issue.companyId, environmentId: environment.id,
owner: computerOwnerFromLease(current), port: input.port });
const actor = getActorInfo(req);
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.preview_opened", entityType: "environment", entityId: environment.id, details: { issueId: issue.id, port: input.port } });
res.json(result);
});
router.use((error: unknown, _req: import("express").Request, res: import("express").Response, next: import("express").NextFunction) => {
if (!(error instanceof ComputerError)) return next(error);
res.status(error.code === "conflict" ? 409 : error.code === "invalid" ? 422 : error.code === "not_found" ? 404 : 502).json({ error: error.message, code: error.code });
});
return router;
}
+71 -4
View File
@@ -44,6 +44,7 @@ import {
collectEnvironmentSecretRefs,
normalizeEnvironmentConfigForPersistence,
normalizeEnvironmentConfigForProbe,
parseEnvironmentDriverConfig,
readSshEnvironmentPrivateKeySecretId,
type ParsedEnvironmentConfig,
} from "../services/environment-config.js";
@@ -54,7 +55,8 @@ import {
type ReadyPluginWorkerRecovery,
} from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
import { assertBoardOrgAccess, assertCompanyAccess, getActorInfo } from "./authz.js";
import { computerService, ComputerError } from "../modules/computers/index.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
@@ -1014,6 +1016,11 @@ export function environmentRoutes(
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCanAccessInstanceEnvironments(req);
assertCompanyAccess(req, companyId);
if (req.body.metadata?.computerCompanyId !== undefined) throw unprocessable("Computer ownership is assigned by Paperclip.");
if (req.body.driver === "computer" && !(await instanceSettings.getExperimental()).enableBoatEnvironments) {
throw unprocessable("Enable Boat environments in experimental settings first.");
}
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
if (req.body.driver === "local") {
const existingLocal = await svc.list({ driver: "local" });
@@ -1024,6 +1031,10 @@ export function environmentRoutes(
const actor = getActorInfo(req);
const input = {
...req.body,
...(req.body.driver === "computer" ? {
status: "inactive" as const,
metadata: { ...req.body.metadata, computerCompanyId: companyId },
} : {}),
envVars: await secrets.normalizeEnvBindingsForPersistence(
companyId,
req.body.envVars,
@@ -1045,7 +1056,7 @@ export function environmentRoutes(
};
// Create the row and its binding rows atomically so an invalid secret
// ref cannot leave an environment persisted without its bindings.
const environment = await db.transaction(async (tx) => {
let environment = await db.transaction(async (tx) => {
const created = await svc.create(input, undefined, { db: tx });
await secrets.replaceSecretRefsForInstanceTarget(
{ targetType: "environment", targetId: created.id },
@@ -1060,6 +1071,13 @@ export function environmentRoutes(
);
return created;
});
if (environment.driver === "computer") {
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration.");
await computerService(db).attach({ companyId, environmentId: environment.id,
sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef });
environment = (await svc.update(environment.id, { status: "active" }))!;
}
await logInstanceEnvironmentActivity({
actor,
action: "environment.created",
@@ -1137,6 +1155,24 @@ export function environmentRoutes(
}),
});
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
if (req.body.metadata?.computerCompanyId !== undefined && req.body.metadata.computerCompanyId !== existing.metadata?.computerCompanyId) {
throw unprocessable("Computer ownership cannot be changed.");
}
if (existing.driver === "computer" || req.body.driver === "computer") {
if (existing.driver !== "computer" || req.body.driver && req.body.driver !== "computer") {
throw unprocessable("Create a separate environment to change its type.");
}
if (req.body.config?.sandboxId !== undefined && req.body.config.sandboxId !== existing.config.sandboxId) {
throw unprocessable("Disconnect this environment and attach the other Boat separately.");
}
const computerCompanyId = existing.metadata?.computerCompanyId;
if (typeof computerCompanyId !== "string") throw unprocessable("Computer attachment has no company.");
assertCompanyAccess(req, computerCompanyId);
if ((req.body.config !== undefined || req.body.status === "active") && !(await instanceSettings.getExperimental()).enableBoatEnvironments) {
throw unprocessable("Boat environments are disabled.");
}
if (req.body.metadata) req.body.metadata = { ...req.body.metadata, computerCompanyId };
}
// The durable `pending_cleanup` lease row stores the provider, the provider
// lease id, and the immutable config metadata for an orphan sandbox. The
// teardown retry reads that row alone and never reads the current environment
@@ -1163,7 +1199,9 @@ export function environmentRoutes(
const nextName = req.body.name ?? existing.name;
const companyIdForSecrets =
req.body.config !== undefined || req.body.driver !== undefined || req.body.envVars !== undefined
? await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true })
? existing.driver === "computer"
? String(existing.metadata?.computerCompanyId)
: await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true })
: null;
const configSource =
req.body.config !== undefined
@@ -1208,7 +1246,7 @@ export function environmentRoutes(
// Persist the config change and its binding rows atomically: a binding
// ref that fails validation (e.g. a deleted secret) must roll the whole
// save back instead of leaving the config re-pointed with stale bindings.
const environment = await db.transaction(async (tx) => {
let environment = await db.transaction(async (tx) => {
const updated = await svc.update(existing.id, patch, { db: tx });
if (!updated) return null;
if (patch.config !== undefined || patch.driver !== undefined) {
@@ -1232,6 +1270,14 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (environment.driver === "computer" && (req.body.config !== undefined || req.body.status === "active")) {
const computerCompanyId = String(environment.metadata?.computerCompanyId);
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration.");
await computerService(db).attach({ companyId: computerCompanyId, environmentId: environment.id,
sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef });
if (req.body.status !== "inactive") environment = (await svc.update(environment.id, { status: "active" }))!;
}
let customImageReconciliation: Awaited<
ReturnType<typeof customImages.reconcileActiveTemplateForConfigChange>
> = { action: "none" };
@@ -1272,6 +1318,23 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (existing.driver === "computer") {
const companyId = existing.metadata?.computerCompanyId;
if (typeof companyId !== "string") throw unprocessable("Computer attachment has no company.");
assertCompanyAccess(req, companyId);
if (impact.staticReferences.isInstanceDefault) throw conflict("Choose another default environment before disconnecting this computer.");
await svc.update(existing.id, { status: "inactive" });
await computerService(db).detach({ companyId, environmentId: existing.id });
await Promise.all([
executionWorkspaces.clearEnvironmentSelection(companyId, existing.id),
issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
]);
await logInstanceEnvironmentActivity({ actor, action: "environment.disconnected", entityId: existing.id,
details: { name: existing.name, driver: "computer", filesRetained: true } });
res.json({ ...existing, status: "inactive", disconnected: true, destroyedReusableSandboxLeaseCount: 0 });
return;
}
// With explicit consent, destroy the environment's reusable sandbox leases
// so the delete can proceed — but only while those leases are the sole
// blocker. Destroying provider resources and then rejecting on another
@@ -1476,5 +1539,9 @@ export function environmentRoutes(
},
);
router.use((error: unknown, _req: import("express").Request, res: import("express").Response, next: import("express").NextFunction) => {
if (!(error instanceof ComputerError)) return next(error);
res.status(error.code === "conflict" ? 409 : error.code === "invalid" ? 422 : error.code === "not_found" ? 404 : 502).json({ error: error.message, code: error.code });
});
return router;
}
+36
View File
@@ -37,6 +37,18 @@ const secretRefSchema = z.object({
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
}).strict();
const computerEnvironmentConfigSchema = z.object({
provider: z.literal("boat"),
sandboxId: z.string().trim().regex(/^bx_[a-zA-Z0-9]+$/, "Enter an existing Boat ID."),
apiKeySecretRef: secretRefSchema,
runnerIdleTimeoutMs: z.number().int().min(1_000).max(86_400_000).optional(),
}).strict();
const computerEnvironmentInputSchema = computerEnvironmentConfigSchema.extend({
apiKeySecretRef: secretRefSchema.optional(),
apiKey: z.string().trim().min(1).optional(),
}).refine((value) => value.apiKey || value.apiKeySecretRef, "A Boat API key is required.");
const sshEnvironmentConfigSchema = z.object({
host: z.string({ error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
port: z.coerce.number().int().min(1).max(65535).default(22),
@@ -121,6 +133,7 @@ const pluginEnvironmentConfigSchema = z.object({
export type ParsedEnvironmentConfig =
| { driver: "local"; config: LocalEnvironmentConfig }
| { driver: "computer"; config: z.infer<typeof computerEnvironmentConfigSchema> }
| { driver: "ssh"; config: SshEnvironmentConfig }
| { driver: "sandbox"; config: SandboxEnvironmentConfig }
| { driver: "plugin"; config: PluginEnvironmentConfig };
@@ -373,6 +386,10 @@ export async function collectEnvironmentSecretRefs(input: {
environment: Pick<Environment, "id" | "driver" | "config">;
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
const parsed = parseEnvironmentDriverConfig(input.environment);
if (parsed.driver === "computer") {
return [{ secretId: parsed.config.apiKeySecretRef.secretId,
configPath: "apiKeySecretRef", versionSelector: parsed.config.apiKeySecretRef.version }];
}
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
return [{
secretId: parsed.config.privateKeySecretRef.secretId,
@@ -437,6 +454,11 @@ export function normalizeEnvironmentConfig(input: {
driver: EnvironmentDriver;
config: Record<string, unknown> | null | undefined;
}): Record<string, unknown> {
if (input.driver === "computer") {
const parsed = computerEnvironmentConfigSchema.safeParse(parseObject(input.config));
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
return parsed.data;
}
if (input.driver === "local") {
return { ...parseObject(input.config) };
}
@@ -551,6 +573,17 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
actor?: { userId?: string | null; agentId?: string | null };
pluginWorkerManager?: PluginWorkerManager;
}): Promise<Record<string, unknown>> {
if (input.driver === "computer") {
const parsed = computerEnvironmentInputSchema.safeParse(parseObject(input.config));
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
const { apiKey, ...stored } = parsed.data;
const apiKeySecretRef = apiKey ? await createEnvironmentSecret({
db: input.db, companyId: input.companyId, environmentName: input.environmentName,
driver: input.driver, field: "boat-api-key", provider: input.secretProvider,
value: apiKey, actor: input.actor,
}) : stored.apiKeySecretRef;
return computerEnvironmentConfigSchema.parse({ ...stored, apiKeySecretRef });
}
if (input.driver === "ssh") {
const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
@@ -788,6 +821,9 @@ export function readSshEnvironmentPrivateKeySecretId(
export function parseEnvironmentDriverConfig(
environment: Pick<Environment, "driver" | "config">,
): ParsedEnvironmentConfig {
if (environment.driver === "computer") {
return { driver: "computer", config: computerEnvironmentConfigSchema.parse(environment.config) };
}
if (environment.driver === "local") {
return {
driver: "local",
+11
View File
@@ -11,6 +11,7 @@ import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provid
import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { computerService } from "../modules/computers/index.js";
export async function probeEnvironment(
db: Db,
@@ -46,6 +47,16 @@ export async function probeEnvironment(
};
}
if (parsed.driver === "computer") {
if (!resolvedCompanyId || environment.metadata?.computerCompanyId !== resolvedCompanyId) {
return { ok: false, driver: "computer", summary: "This computer belongs to a different company.", details: null };
}
const result = await computerService(db).inspect({ companyId: resolvedCompanyId, environmentId: environment.id });
return { ok: result.status === "attached", driver: "computer",
summary: result.status === "attached" ? `Boat is attached (${result.state}).` : "Boat is not attached.",
details: { sandboxId: result.sandboxId, state: result.state } };
}
if (parsed.driver === "sandbox") {
if (options.acquireSandboxRuntimeLease) {
if (!resolvedCompanyId) {
+2
View File
@@ -225,6 +225,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
enableBoatEnvironments: parsed.data.enableBoatEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
@@ -275,6 +276,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableNativeRunner: true,
enableAiConnectionRouters: false,
enableManagedSandboxOnly: false,
enableBoatEnvironments: false,
enableIsolatedWorkspaces: false,
enableIsolatedWorkspacesByDefault: false,
enableStreamlinedLeftNavigation: true,
+15
View File
@@ -0,0 +1,15 @@
import type { ComputerViewer, TaskComputer } from "@paperclipai/shared";
import { api } from "./client";
const path = (issueId: string) => `/issues/${encodeURIComponent(issueId)}/computer`;
export const computersApi = {
get: (issueId: string) => api.get<TaskComputer | null>(path(issueId), { cache: "no-store" }),
connect: (issueId: string, environmentId: string) =>
api.post<ComputerViewer>(`${path(issueId)}/connect`, { environmentId }),
presence: (issueId: string, environmentId: string, owner: ComputerViewer["owner"]) =>
api.post<ComputerViewer>(`${path(issueId)}/presence`, { environmentId, owner }),
disconnect: (issueId: string, environmentId: string, owner: ComputerViewer["owner"]) =>
api.post<void>(`${path(issueId)}/disconnect`, { environmentId, owner }),
preview: (issueId: string, environmentId: string, port: number) =>
api.post<{ url: string }>(`${path(issueId)}/preview`, { environmentId, port }),
};
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from "react";
import type { ComputerViewer } from "@paperclipai/shared";
import { LoaderCircle, Monitor } from "lucide-react";
import { computersApi } from "@/api/computers";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function TaskComputerPanel({ issueId, environmentId, active = true }: {
issueId: string;
environmentId: string;
active?: boolean;
}) {
const [viewer, setViewer] = useState<ComputerViewer | null>(null);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const current = useRef<ComputerViewer | null>(null);
const mounted = useRef(true);
const [port, setPort] = useState("5173");
const [previewPending, setPreviewPending] = useState(false);
async function openPreview() {
const previewWindow = window.open("about:blank", "_blank");
if (!previewWindow) { setError("Allow a new tab to open the preview."); return; }
previewWindow.opener = null;
setPreviewPending(true);
setError(null);
try {
const result = await computersApi.preview(issueId, environmentId, Number(port));
previewWindow.location.replace(result.url);
} catch (cause) {
previewWindow.close();
setError(cause instanceof Error ? cause.message : "Could not open the preview.");
} finally { setPreviewPending(false); }
}
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
const previous = current.current;
current.current = null;
if (previous) void computersApi.disconnect(issueId, environmentId, previous.owner).catch(() => {
// The server's bounded presence deadline also releases lost viewers.
});
};
}, [issueId, environmentId]);
async function connect() {
if (connecting) return;
setConnecting(true);
setError(null);
try {
const result = await computersApi.connect(issueId, environmentId);
if (!mounted.current) {
await computersApi.disconnect(issueId, environmentId, result.owner);
return;
}
current.current = result;
setViewer(result);
} catch (cause) {
if (mounted.current) setError(cause instanceof Error ? cause.message : "Could not connect to the computer.");
} finally {
if (mounted.current) setConnecting(false);
}
}
useEffect(() => {
if (!viewer) return;
let disposed = false;
let pending = false;
const renew = async () => {
if (pending || !active || document.visibilityState === "hidden") return;
pending = true;
try {
const next = await computersApi.presence(issueId, environmentId, viewer.owner);
if (!disposed) {
current.current = next;
setViewer(next);
}
} catch (cause) {
if (!disposed) {
current.current = null;
setViewer(null);
setError(cause instanceof Error ? cause.message : "The connection ended. Connect to try again.");
}
} finally { pending = false; }
};
const timer = setInterval(() => void renew(), 30_000);
document.addEventListener("visibilitychange", renew);
return () => {
disposed = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", renew);
};
}, [issueId, environmentId, viewer?.owner.ownerId, active]);
const desktop = viewer ? <iframe
title="Agent computer"
src={viewer.viewerUrl}
className="h-full w-full border-0"
referrerPolicy="no-referrer"
allow="clipboard-read; clipboard-write; fullscreen; autoplay"
/> : <div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<Monitor className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Open the shared computer to see and control your agents’ desktop.</p>
<Button onClick={() => void connect()} disabled={connecting}>
{connecting && <LoaderCircle className="size-4 animate-spin" />}
{connecting ? "Connecting…" : "Connect"}
</Button>
</div>;
return <div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">{desktop}</div>
<div className="flex shrink-0 flex-col gap-2 border-t p-3">
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<form className="flex items-center gap-2" onSubmit={event => { event.preventDefault(); void openPreview(); }}>
<label htmlFor={`preview-port-${environmentId}`} className="text-sm text-muted-foreground">Port</label>
<Input id={`preview-port-${environmentId}`} type="number" min="1024" max="65535" value={port}
className="min-w-0 flex-1" onChange={event => setPort(event.target.value)} />
<Button type="submit" variant="outline" disabled={previewPending || !port}>Open preview</Button>
</form>
</div>
</div>;
}
@@ -2,6 +2,8 @@ import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
import { TaskBrowserPanel } from "./TaskBrowserPanel";
import { TaskComputerPanel } from "./TaskComputerPanel";
import { computersApi } from "@/api/computers";
import {
useCallback,
useEffect,
@@ -19,6 +21,7 @@ import {
} from "@paperclipai/shared";
import {
Globe,
Monitor,
Box,
FileCode2,
FileText,
@@ -72,6 +75,7 @@ import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelBrowserTab,
taskPanelComputerTab,
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelAttachmentTab,
@@ -129,6 +133,7 @@ const EMPTY_ISSUE_DOCUMENTS: IssueDocument[] = [];
function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
switch (tab.payload.kind) {
case "browser": return <Globe />;
case "computer": return <Monitor />;
case "properties": return <SlidersHorizontal />;
case "subtasks": return <ListTree />;
case "artifacts": return <Box />;
@@ -338,6 +343,8 @@ export function TaskSidePanel({
}, [accountScope, issue.companyId, issue.id, launcherOpen]);
const controller = useSidePanelTabs<TaskSidePanelTabPayload>({ initialState, onStateChange: persist });
const browsersQuery = useTaskBrowsers(issue.id);
const computerQuery = useQuery({ queryKey: ["task-computer", issue.id],
queryFn: () => computersApi.get(issue.id), refetchInterval: 30_000 });
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
useEffect(() => {
if (!openBrowserId) return;
@@ -584,6 +591,9 @@ export function TaskSidePanel({
for (const [index, browser] of (browsersQuery.data ?? []).entries()) {
primary.push({ id: `browser:${browser.id}`, label: (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${index + 1}` : "Browser", description: browser.status, icon: <Globe />, alreadyOpen: controller.tabs.some(tab => tab.id === `browser:${browser.id}`) });
}
if (computerQuery.data) primary.push({ id: `computer:${computerQuery.data.environmentId}`,
label: "Computer", description: computerQuery.data.name, icon: <Monitor />,
alreadyOpen: controller.tabs.some(tab => tab.id === `computer:${computerQuery.data!.environmentId}`) });
if (fileTabsEnabled) {
primary.push({ id: "files", label: "Files", icon: <FolderOpen />, shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") });
}
@@ -631,11 +641,12 @@ export function TaskSidePanel({
});
}
return sections;
}, [browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
}, [browsersQuery.data, computerQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
if (item.id.startsWith("browser:")) controller.openTab(taskPanelBrowserTab(item.id.slice(8)));
else if (item.id.startsWith("computer:")) controller.openTab(taskPanelComputerTab(item.id.slice(9)));
else if (item.id === "properties") controller.openTab(taskPanelPropertiesTab());
else if (item.id === "subtasks") {
subtasksDismissedRef.current = false;
@@ -720,6 +731,8 @@ export function TaskSidePanel({
let content: ReactNode;
if (!activeTab) {
content = <SidePanelLauncher sections={launcherSections} onSelect={selectLauncherItem} />;
} else if (activeTab.payload.kind === "computer") {
content = null;
} else if (activeTab.payload.kind === "browser") {
content = null; // Mounted below independently of the selected tab.
} else if (activeTab.payload.kind === "properties") {
@@ -843,6 +856,11 @@ export function TaskSidePanel({
contentMode === "padded" && "p-4",
)}
>
{controller.tabs.map(tab => tab.payload.kind === "computer" ? (
<div key={tab.id} hidden={tab.id !== controller.activeTabId} className={cn("h-full min-h-0", tab.id !== controller.activeTabId && "hidden")}>
<TaskComputerPanel active={tab.id === controller.activeTabId} issueId={issue.id} environmentId={tab.payload.environmentId} />
</div>
) : null)}
{controller.tabs.map(tab => tab.payload.kind === "browser" ? (
<div key={tab.id} hidden={tab.id !== controller.activeTabId} className={cn("h-full min-h-0", tab.id !== controller.activeTabId && "hidden")}>
<TaskBrowserPanel active={tab.id === controller.activeTabId} issueId={issue.id} browser={browsersQuery.data?.find(b => tab.payload.kind === "browser" && (b.id === tab.payload.browserId || b.sessionId === tab.payload.browserId))} accessError={browsersQuery.isError} onOpenActiveBrowser={newestLiveBrowser ? () => controller.openTab(taskPanelBrowserTab(newestLiveBrowser.id)) : undefined} />
+8
View File
@@ -30,6 +30,7 @@ export function openSkillPanelState(
}
export type TaskSidePanelTabPayload =
| { kind: "computer"; environmentId: string }
| { kind: "browser"; browserId: string }
| { kind: "properties" }
| { kind: "subtasks" }
@@ -91,6 +92,8 @@ function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
const input = record(value);
if (!input) return null;
const kind = input.kind;
if (kind === "computer") return typeof input.environmentId === "string" && /^[0-9a-f-]{36}$/i.test(input.environmentId)
? { kind, environmentId: input.environmentId } : null;
if (kind === "browser") return typeof input.browserId === "string" && /^[0-9a-f-]{36}$/i.test(input.browserId) ? { kind, browserId: input.browserId } : null;
if (kind === "properties") return { kind };
if (kind === "subtasks") return { kind };
@@ -296,6 +299,11 @@ export function taskPanelAttachmentTab(attachmentId: string, title: string): Sid
return { id: `attachment:${attachmentId}`, type: "attachment", label: title, closable: true, contentMode: "full-bleed", payload: { kind: "attachment", attachmentId } };
}
export function taskPanelComputerTab(environmentId: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `computer:${environmentId}`, type: "computer", label: "Computer", closable: true,
contentMode: "full-bleed", payload: { kind: "computer", environmentId } };
}
export function taskPanelBrowserTab(browserId: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `browser:${browserId}`, type: "browser", label: "Browser", closable: true, contentMode: "full-bleed", payload: { kind: "browser", browserId } };
}
+52 -13
View File
@@ -56,6 +56,7 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { environmentDisplayLabel, isPlatformManagedEnvironment } from "@/lib/managed-sandbox-environment";
import { Input } from "@/components/ui/input";
import { queryKeys } from "@/lib/queryKeys";
import { Link, useNavigate, useParams } from "@/lib/router";
import { buildSameOriginWebSocketUrl } from "@/lib/websocket-url";
@@ -67,7 +68,10 @@ import {
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
driver: "local" | "ssh" | "sandbox" | "computer";
boatId: string;
boatApiKey: string;
boatApiKeySecretId: string;
sshHost: string;
sshPort: string;
sshUsername: string;
@@ -119,7 +123,13 @@ function buildEnvironmentPayload(form: EnvironmentFormState) {
driver: form.driver,
envVars: form.envVars,
config:
form.driver === "ssh"
form.driver === "computer" ? {
provider: "boat",
sandboxId: form.boatId.trim(),
...(form.boatApiKey.trim() ? { apiKey: form.boatApiKey.trim() } : {
apiKeySecretRef: { type: "secret_ref" as const, secretId: form.boatApiKeySecretId, version: "latest" as const },
}),
} : form.driver === "ssh"
? {
host: form.sshHost.trim(),
port: Number.parseInt(form.sshPort || "22", 10) || 22,
@@ -147,6 +157,9 @@ function createEmptyEnvironmentForm(): EnvironmentFormState {
name: "",
description: "",
driver: "ssh",
boatId: "",
boatApiKey: "",
boatApiKeySecretId: "",
sshHost: "",
sshPort: "22",
sshUsername: "",
@@ -215,6 +228,14 @@ function readSandboxConfig(environment: Environment) {
}
function createEnvironmentFormFromEnvironment(environment: Environment): EnvironmentFormState {
if (environment.driver === "computer") {
const ref = environment.config.apiKeySecretRef;
return { ...createEmptyEnvironmentForm(), name: environment.name,
description: environment.description ?? "", driver: "computer",
boatId: typeof environment.config.sandboxId === "string" ? environment.config.sandboxId : "",
boatApiKeySecretId: ref && typeof ref === "object" && "secretId" in ref && typeof ref.secretId === "string" ? ref.secretId : "",
envVars: environment.envVars ?? {} };
}
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
return {
@@ -1567,9 +1588,9 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
navigate(ENVIRONMENTS_PATH, { replace: true });
const destroyedCount = environment.destroyedReusableSandboxLeaseCount ?? 0;
pushToast({
title: "Environment deleted",
title: environment.driver === "computer" ? "Computer disconnected" : "Environment deleted",
body:
destroyedCount > 0
environment.driver === "computer" ? `${environment.name} was disconnected. Its files are retained on Boat.` : destroyedCount > 0
? `${environment.name} was deleted. Destroyed ${destroyedCount === 1 ? "1 reusable sandbox" : `${destroyedCount} reusable sandboxes`}.`
: `${environment.name} was deleted.`,
tone: "success",
@@ -1834,6 +1855,9 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
const environmentFormValid =
environmentForm.name.trim().length > 0 &&
(environmentForm.driver !== "computer" ||
/^bx_[a-zA-Z0-9]+$/.test(environmentForm.boatId.trim()) &&
Boolean(environmentForm.boatApiKey.trim() || environmentForm.boatApiKeySecretId)) &&
(environmentForm.driver !== "ssh" ||
(
environmentForm.sshHost.trim().length > 0 &&
@@ -1900,7 +1924,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
`${selectionCount} workspace, issue, or project environment ${selectionCount === 1 ? "selection" : "selections"} will be cleared.`,
);
}
if (deleteBlastRadius.staticReferences.secretBindingCount > 0) {
if (editingEnvironment?.driver !== "computer" && deleteBlastRadius.staticReferences.secretBindingCount > 0) {
deleteImpactNotes.push(
`${deleteBlastRadius.staticReferences.secretBindingCount} secret ${deleteBlastRadius.staticReferences.secretBindingCount === 1 ? "binding" : "bindings"} will be removed.`,
);
@@ -2016,7 +2040,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
<span>
{environment.name}
{isPlatformManagedEnvironment(environment) ? null : (
<span className="text-muted-foreground"> · {environment.driver}</span>
<span className="text-muted-foreground"> · {environment.driver === "computer" ? "Boat" : environment.driver}</span>
)}
</span>
{isPlatformManagedEnvironment(environment) ? (
@@ -2195,8 +2219,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
aria-label={`Delete ${editingEnvironment.name}`}
title="Delete environment"
aria-label={`${editingEnvironment.driver === "computer" ? "Disconnect" : "Delete"} ${editingEnvironment.name}`}
title={editingEnvironment.driver === "computer" ? "Disconnect computer" : "Delete environment"}
data-testid="environment-delete-button"
onClick={() => {
setReassignEnvironmentTargetId("");
@@ -2231,7 +2255,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<Field label="Driver" hint="Sandbox stores plugin-backed provider config on the shared environment seam. SSH stores a remote machine target.">
<Field label="Environment type" hint="Choose where your agents run.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.driver}
@@ -2252,19 +2276,32 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
: {}
)
: current.sandboxConfig,
driver: e.target.value === "sandbox" ? "sandbox" : "ssh",
driver: e.target.value === "computer" ? "computer" : e.target.value === "sandbox" ? "sandbox" : "ssh",
}))}
>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="ssh">SSH</option>
{(experimentalSettings?.enableBoatEnvironments || environmentForm.driver === "computer") && <option value="computer">Boat</option>}
{environmentForm.driver === "local" ? (
<option value="local">Local</option>
) : null}
</select>
</Field>
{environmentForm.driver === "computer" && <>
<Field label="Boat ID" hint="Attach an existing Boat with persistent storage enabled.">
<Input value={environmentForm.boatId} placeholder="bx_…"
onChange={(event) => setEnvironmentForm(current => ({ ...current, boatId: event.target.value }))} />
</Field>
<Field label="Boat API key" hint={environmentForm.boatApiKeySecretId ? "A key is saved. Leave blank to keep it." : "Stored as an encrypted secret."}>
<Input type="password" autoComplete="new-password" value={environmentForm.boatApiKey}
onChange={(event) => setEnvironmentForm(current => ({ ...current, boatApiKey: event.target.value }))} />
</Field>
<p className="text-sm text-muted-foreground">Agents share this computer’s desktop and have separate personal folders. Paperclip connects when needed and suspends it after the last runner’s warm timeout. Disconnecting the environment keeps its files.</p>
</>}
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
@@ -2470,7 +2507,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
>
Cancel
</Button>
{environmentForm.driver !== "local" ? (
{environmentForm.driver !== "local" && environmentForm.driver !== "computer" ? (
<Button
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(flushEnvironmentForm())}
@@ -2503,7 +2540,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
>
<AlertDialogContent data-testid="environment-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete {editingEnvironment.name}?</AlertDialogTitle>
<AlertDialogTitle>{editingEnvironment.driver === "computer" ? "Disconnect" : "Delete"} {editingEnvironment.name}?</AlertDialogTitle>
<AlertDialogDescription>
{deleteUsageLoading
? "Checking what uses this environment..."
@@ -2511,6 +2548,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
? "Could not check what uses this environment. Close this dialog and retry."
: deleteBlockMessage
?? ([
editingEnvironment.driver === "computer" ? "Paperclip will stop using this computer. Its filesystem will be retained on Boat." : null,
reusableLeaseOnlyBlock && deleteBlastRadius
? `${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 reusable sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} reusable sandboxes`} will be destroyed; the workspaces holding them stay open and provision a fresh sandbox on their next run.`
: null,
@@ -2615,7 +2653,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
}}
>
{deleteEnvironmentMutation.isPending
? "Deleting..."
? editingEnvironment.driver === "computer" ? "Disconnecting..." : "Deleting..."
: editingEnvironment.driver === "computer" ? "Disconnect computer"
: reusableLeaseOnlyBlock && deleteBlastRadius
? `Destroy ${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} sandboxes`} and delete`
: "Delete environment"}
@@ -244,6 +244,7 @@ export function InstanceExperimentalSettings() {
const showDeveloperSection = showWorktreeRunExecution || ([
"autoRestartDevServerWhenIdle",
"enableManagedSandboxOnly",
"enableBoatEnvironments",
"enablePaperclipDeveloperMode",
"enableServerInfoDebugView",
"enableSmokeLab",
@@ -620,6 +621,17 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle managed environment only experimental setting"
/>
<ExperimentalToggleCard
title="Boat Environments"
description="Attach an existing Boat to run agents with persistent files and a shared desktop."
checked={experimentalQuery.data?.enableBoatEnvironments === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBoatEnvironments: checked })}
disabled={toggleMutation.isPending}
settingKey="enableBoatEnvironments"
managed={managedKeys.enableBoatEnvironments}
ariaLabel="Toggle Boat environments"
/>
<ExperimentalToggleCard
title="Paperclip Developer Mode"
description="Show internal Paperclip maintainer tools and observability links, including Honeycomb trace queries on run pages."