mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat: add experimental Boat configuration and computer viewer surfaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user