diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 062482ecad5..9f095939492 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -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" }; diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts index 973da0bf674..6ebbd037ad6 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts @@ -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 { 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"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 71103812f2a..3bc94c1cb2a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9dba828880f..db9148968f6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/types/computer-view.ts b/packages/shared/src/types/computer-view.ts new file mode 100644 index 00000000000..48e731d0838 --- /dev/null +++ b/packages/shared/src/types/computer-view.ts @@ -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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index be561795fda..e404a260911 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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"; diff --git a/server/src/app.ts b/server/src/app.ts index c622f62e27c..7d17b92ce03 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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( diff --git a/server/src/index.ts b/server/src/index.ts index 6bef07ad23a..5f021d4d513 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(); 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], diff --git a/server/src/routes/computers.ts b/server/src/routes/computers.ts new file mode 100644 index 00000000000..a1e00466f57 --- /dev/null +++ b/server/src/routes/computers.ts @@ -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; +} diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 68d51234cb5..b29668df5e7 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -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 > = { 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; } diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index d1d1dc184c1..e7565b1fecb 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -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 } | { driver: "ssh"; config: SshEnvironmentConfig } | { driver: "sandbox"; config: SandboxEnvironmentConfig } | { driver: "plugin"; config: PluginEnvironmentConfig }; @@ -373,6 +386,10 @@ export async function collectEnvironmentSecretRefs(input: { environment: Pick; }): Promise> { 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 | null | undefined; }): Record { + 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> { + 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, ): ParsedEnvironmentConfig { + if (environment.driver === "computer") { + return { driver: "computer", config: computerEnvironmentConfigSchema.parse(environment.config) }; + } if (environment.driver === "local") { return { driver: "local", diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index ee1bce5d4c4..8461273698c 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -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) { diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index e6e0932e4a9..f46cdf7a08c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -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, diff --git a/ui/src/api/computers.ts b/ui/src/api/computers.ts new file mode 100644 index 00000000000..b44776d78c2 --- /dev/null +++ b/ui/src/api/computers.ts @@ -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(path(issueId), { cache: "no-store" }), + connect: (issueId: string, environmentId: string) => + api.post(`${path(issueId)}/connect`, { environmentId }), + presence: (issueId: string, environmentId: string, owner: ComputerViewer["owner"]) => + api.post(`${path(issueId)}/presence`, { environmentId, owner }), + disconnect: (issueId: string, environmentId: string, owner: ComputerViewer["owner"]) => + api.post(`${path(issueId)}/disconnect`, { environmentId, owner }), + preview: (issueId: string, environmentId: string, port: number) => + api.post<{ url: string }>(`${path(issueId)}/preview`, { environmentId, port }), +}; diff --git a/ui/src/components/task-side-panel/TaskComputerPanel.tsx b/ui/src/components/task-side-panel/TaskComputerPanel.tsx new file mode 100644 index 00000000000..14df73eae4e --- /dev/null +++ b/ui/src/components/task-side-panel/TaskComputerPanel.tsx @@ -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(null); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + const current = useRef(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 ?