diff --git a/packages/adapters/codex-local/src/server/test.remote.test.ts b/packages/adapters/codex-local/src/server/test.remote.test.ts index 787dbde338..3d77649f5e 100644 --- a/packages/adapters/codex-local/src/server/test.remote.test.ts +++ b/packages/adapters/codex-local/src/server/test.remote.test.ts @@ -218,9 +218,9 @@ describe("codex remote environment diagnostics", () => { }); it("does not override CODEX_HOME when the host has no credentials to seed", async () => { - // Pre-authenticated sandbox flow: the login lives inside the sandbox image, - // and the host has no Codex auth.json. The probe must not upload an empty - // home or set CODEX_HOME, so Codex falls back to the sandbox's baked-in login. + // Custom-image flow: the login lives inside the captured snapshot, and the + // host has no Codex auth.json. The probe must not upload an empty home or + // set CODEX_HOME, so Codex falls back to the sandbox's baked-in login. prepareManagedCodexHome.mockImplementationOnce(async () => { const dir = await fs.mkdtemp(`${os.tmpdir()}/paperclip-managed-codex-home-noauth-`); // No auth.json — only a config file. diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 0c9772048c..675bc5384c 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -117,8 +117,8 @@ async function prepareCodexHelloProbe(input: { // When the host has no Codex credentials to seed, don't override CODEX_HOME. // Pointing Codex at an empty uploaded home would mask any login already - // baked into a prepared sandbox image; leaving CODEX_HOME unset lets the - // probe exercise that in-sandbox login instead. + // baked into the sandbox (e.g. a captured custom-image snapshot); leaving + // CODEX_HOME unset lets the probe exercise that in-sandbox login instead. if (!seededAuth) { return { command: input.command, diff --git a/packages/db/src/environment-custom-images-schema.test.ts b/packages/db/src/environment-custom-images-schema.test.ts new file mode 100644 index 0000000000..a3761a9e32 --- /dev/null +++ b/packages/db/src/environment-custom-images-schema.test.ts @@ -0,0 +1,46 @@ +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { environmentCustomImageSetupSessions } from "./schema/environment_custom_image_setup_sessions.js"; +import { environmentCustomImageTemplates } from "./schema/environment_custom_image_templates.js"; + +function indexColumns(table: Parameters[0], indexName: string): string[] { + const index = getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName); + if (!index) return []; + return index.config.columns.map((column) => (column as { name: string }).name); +} + +function indexWhere(table: Parameters[0], indexName: string): unknown { + return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName)?.config.where; +} + +describe("environment customImage schema", () => { + it("scopes template lookup and active uniqueness by company and environment", () => { + expect(indexColumns( + environmentCustomImageTemplates, + "environment_custom_image_templates_company_environment_status_idx", + )).toEqual(["company_id", "environment_id", "status"]); + expect(indexColumns( + environmentCustomImageTemplates, + "environment_custom_image_templates_company_environment_active_uq", + )).toEqual(["company_id", "environment_id"]); + expect(indexWhere( + environmentCustomImageTemplates, + "environment_custom_image_templates_company_environment_active_uq", + )).toBeDefined(); + }); + + it("scopes setup-session lookup and active uniqueness by company and environment", () => { + expect(indexColumns( + environmentCustomImageSetupSessions, + "environment_custom_image_setup_sessions_company_environment_status_idx", + )).toEqual(["company_id", "environment_id", "status"]); + expect(indexColumns( + environmentCustomImageSetupSessions, + "environment_custom_image_setup_sessions_company_environment_active_uq", + )).toEqual(["company_id", "environment_id"]); + expect(indexWhere( + environmentCustomImageSetupSessions, + "environment_custom_image_setup_sessions_company_environment_active_uq", + )).toBeDefined(); + }); +}); diff --git a/packages/db/src/migrations/0125_environment_custom_image_templates.sql b/packages/db/src/migrations/0125_environment_custom_image_templates.sql new file mode 100644 index 0000000000..4d9c7bccd5 --- /dev/null +++ b/packages/db/src/migrations/0125_environment_custom_image_templates.sql @@ -0,0 +1,147 @@ +CREATE TABLE "environment_custom_image_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "environment_id" uuid NOT NULL, + "provider" text NOT NULL, + "template_kind" text DEFAULT 'unknown' NOT NULL, + "template_ref" text NOT NULL, + "source_template_ref" text, + "source_environment_config_fingerprint" text, + "status" text DEFAULT 'active' NOT NULL, + "created_by_user_id" text, + "created_by_agent_id" uuid, + "captured_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "superseded_by_template_id" uuid, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "environment_custom_image_setup_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "environment_id" uuid NOT NULL, + "template_id" uuid, + "promoted_template_id" uuid, + "provider" text NOT NULL, + "provider_lease_id" text, + "environment_lease_id" uuid, + "status" text DEFAULT 'starting' NOT NULL, + "started_by_user_id" text, + "started_by_agent_id" uuid, + "base_template_ref" text, + "expires_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "failure_reason" text, + "connection_summary" jsonb, + "connection_secret_ref" text, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + ADD CONSTRAINT "environment_custom_image_templates_company_id_companies_id_fk" + FOREIGN KEY ("company_id") + REFERENCES "public"."companies"("id") + ON DELETE cascade + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + ADD CONSTRAINT "environment_custom_image_templates_environment_id_environments_id_fk" + FOREIGN KEY ("environment_id") + REFERENCES "public"."environments"("id") + ON DELETE cascade + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + ADD CONSTRAINT "environment_custom_image_templates_created_by_agent_id_agents_id_fk" + FOREIGN KEY ("created_by_agent_id") + REFERENCES "public"."agents"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + ADD CONSTRAINT "environment_custom_image_templates_superseded_by_template_id_fk" + FOREIGN KEY ("superseded_by_template_id") + REFERENCES "public"."environment_custom_image_templates"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_company_id_companies_id_fk" + FOREIGN KEY ("company_id") + REFERENCES "public"."companies"("id") + ON DELETE cascade + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_environment_id_environments_id_fk" + FOREIGN KEY ("environment_id") + REFERENCES "public"."environments"("id") + ON DELETE cascade + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_template_id_fk" + FOREIGN KEY ("template_id") + REFERENCES "public"."environment_custom_image_templates"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_promoted_template_id_fk" + FOREIGN KEY ("promoted_template_id") + REFERENCES "public"."environment_custom_image_templates"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk" + FOREIGN KEY ("environment_lease_id") + REFERENCES "public"."environment_leases"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + ADD CONSTRAINT "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk" + FOREIGN KEY ("started_by_agent_id") + REFERENCES "public"."agents"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_company_environment_status_idx" + ON "environment_custom_image_templates" USING btree ("company_id", "environment_id", "status"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_company_provider_status_idx" + ON "environment_custom_image_templates" USING btree ("company_id", "provider", "status"); +--> statement-breakpoint +CREATE UNIQUE INDEX "environment_custom_image_templates_company_environment_active_uq" + ON "environment_custom_image_templates" USING btree ("company_id", "environment_id") + WHERE "status" = 'active'; +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_superseded_by_idx" + ON "environment_custom_image_templates" USING btree ("superseded_by_template_id"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_templates_company_last_used_idx" + ON "environment_custom_image_templates" USING btree ("company_id", "last_used_at"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_company_environment_status_idx" + ON "environment_custom_image_setup_sessions" USING btree ("company_id", "environment_id", "status"); +--> statement-breakpoint +CREATE UNIQUE INDEX "environment_custom_image_setup_sessions_company_environment_active_uq" + ON "environment_custom_image_setup_sessions" USING btree ("company_id", "environment_id") + WHERE "status" IN ('starting', 'waiting_for_user', 'capturing'); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_company_template_idx" + ON "environment_custom_image_setup_sessions" USING btree ("company_id", "template_id"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_company_promoted_template_idx" + ON "environment_custom_image_setup_sessions" USING btree ("company_id", "promoted_template_id"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_company_expires_idx" + ON "environment_custom_image_setup_sessions" USING btree ("company_id", "expires_at"); +--> statement-breakpoint +CREATE INDEX "environment_custom_image_setup_sessions_provider_lease_idx" + ON "environment_custom_image_setup_sessions" USING btree ("provider", "provider_lease_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 6c63f8b5d9..5d52de4305 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -876,6 +876,13 @@ "when": 1782440000000, "tag": "0124_agent_api_key_scope_config", "breakpoints": true + }, + { + "idx": 125, + "version": "7", + "when": 1782440100000, + "tag": "0125_environment_custom_image_templates", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/environment_custom_image_setup_sessions.ts b/packages/db/src/schema/environment_custom_image_setup_sessions.ts new file mode 100644 index 0000000000..e88af26b89 --- /dev/null +++ b/packages/db/src/schema/environment_custom_image_setup_sessions.ts @@ -0,0 +1,64 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import type { + EnvironmentCustomImageSetupConnectionSummary, + EnvironmentCustomImageSetupSessionStatus, +} from "@paperclipai/shared"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { environmentLeases } from "./environment_leases.js"; +import { environmentCustomImageTemplates } from "./environment_custom_image_templates.js"; +import { environments } from "./environments.js"; + +export const environmentCustomImageSetupSessions = pgTable( + "environment_custom_image_setup_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }), + templateId: uuid("template_id").references(() => environmentCustomImageTemplates.id, { onDelete: "set null" }), + promotedTemplateId: uuid("promoted_template_id") + .references(() => environmentCustomImageTemplates.id, { onDelete: "set null" }), + provider: text("provider").notNull(), + providerLeaseId: text("provider_lease_id"), + environmentLeaseId: uuid("environment_lease_id").references(() => environmentLeases.id, { onDelete: "set null" }), + status: text("status").$type().notNull().default("starting"), + startedByUserId: text("started_by_user_id"), + startedByAgentId: uuid("started_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + baseTemplateRef: text("base_template_ref"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + failureReason: text("failure_reason"), + connectionSummary: jsonb("connection_summary").$type(), + connectionSecretRef: text("connection_secret_ref"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyEnvironmentStatusIdx: index("environment_custom_image_setup_sessions_company_environment_status_idx").on( + table.companyId, + table.environmentId, + table.status, + ), + companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_company_environment_active_uq") + .on(table.companyId, table.environmentId) + .where(sql`${table.status} IN ('starting', 'waiting_for_user', 'capturing')`), + companyTemplateIdx: index("environment_custom_image_setup_sessions_company_template_idx").on( + table.companyId, + table.templateId, + ), + companyPromotedTemplateIdx: index("environment_custom_image_setup_sessions_company_promoted_template_idx").on( + table.companyId, + table.promotedTemplateId, + ), + companyExpiresIdx: index("environment_custom_image_setup_sessions_company_expires_idx").on( + table.companyId, + table.expiresAt, + ), + providerLeaseIdx: index("environment_custom_image_setup_sessions_provider_lease_idx").on( + table.provider, + table.providerLeaseId, + ), + }), +); diff --git a/packages/db/src/schema/environment_custom_image_templates.ts b/packages/db/src/schema/environment_custom_image_templates.ts new file mode 100644 index 0000000000..aadd8080ba --- /dev/null +++ b/packages/db/src/schema/environment_custom_image_templates.ts @@ -0,0 +1,59 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + index, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import type { EnvironmentCustomImageTemplateStatus } from "@paperclipai/shared"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { environments } from "./environments.js"; + +export const environmentCustomImageTemplates = pgTable( + "environment_custom_image_templates", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + templateKind: text("template_kind").notNull().default("unknown"), + templateRef: text("template_ref").notNull(), + sourceTemplateRef: text("source_template_ref"), + sourceEnvironmentConfigFingerprint: text("source_environment_config_fingerprint"), + status: text("status").$type().notNull().default("active"), + createdByUserId: text("created_by_user_id"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + capturedAt: timestamp("captured_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + supersededByTemplateId: uuid("superseded_by_template_id") + .references((): AnyPgColumn => environmentCustomImageTemplates.id, { onDelete: "set null" }), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyEnvironmentStatusIdx: index("environment_custom_image_templates_company_environment_status_idx").on( + table.companyId, + table.environmentId, + table.status, + ), + companyProviderStatusIdx: index("environment_custom_image_templates_company_provider_status_idx").on( + table.companyId, + table.provider, + table.status, + ), + companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_templates_company_environment_active_uq") + .on(table.companyId, table.environmentId) + .where(sql`${table.status} = 'active'`), + supersededByIdx: index("environment_custom_image_templates_superseded_by_idx").on(table.supersededByTemplateId), + companyLastUsedIdx: index("environment_custom_image_templates_company_last_used_idx").on( + table.companyId, + table.lastUsedAt, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 8bd5f0bfab..314b783441 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -27,6 +27,8 @@ export { projectWorkspaces } from "./project_workspaces.js"; export { executionWorkspaces } from "./execution_workspaces.js"; export { environments } from "./environments.js"; export { environmentLeases } from "./environment_leases.js"; +export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js"; +export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js"; export { workspaceOperations } from "./workspace_operations.js"; export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/src/manifest.ts b/packages/plugins/paperclip-plugin-fake-sandbox/src/manifest.ts index 6f9f8cf659..1f58317249 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/src/manifest.ts +++ b/packages/plugins/paperclip-plugin-fake-sandbox/src/manifest.ts @@ -23,6 +23,15 @@ const manifest: PaperclipPluginManifestV1 = { displayName: "Fake Sandbox Provider", description: "Runs commands in an isolated local temporary directory while exercising the sandbox provider plugin lifecycle.", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { + field: "snapshot", + unsetFields: ["image"], + }, + supportsTemplateDelete: true, configSchema: { type: "object", properties: { diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.test.ts b/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.test.ts index 186d4e606d..0edf64298a 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.test.ts +++ b/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.test.ts @@ -21,6 +21,11 @@ describe("fake sandbox provider plugin", () => { onDestroyLease: definition.onEnvironmentDestroyLease, onRealizeWorkspace: definition.onEnvironmentRealizeWorkspace, onExecute: definition.onEnvironmentExecute, + onStartInteractiveSetup: definition.onEnvironmentStartInteractiveSetup, + onGetInteractiveSetup: definition.onEnvironmentGetInteractiveSetup, + onCaptureTemplate: definition.onEnvironmentCaptureTemplate, + onCancelInteractiveSetup: definition.onEnvironmentCancelInteractiveSetup, + onDeleteTemplate: definition.onEnvironmentDeleteTemplate, }, }); const base = { @@ -69,6 +74,88 @@ describe("fake sandbox provider plugin", () => { stdout: "fake-plugin-ok", }); + const setup = await harness.startInteractiveSetup({ + ...base, + sessionId: "setup-1", + sourceTemplateRef: "source-template-secret", + expiresAt: "2030-01-01T00:00:00.000Z", + }); + expect(setup).toMatchObject({ + providerLeaseId: "fake-plugin-setup://env-1/setup-1", + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + }, + connectionPayload: { + type: "ssh", + command: "ssh sandbox@[fake-setup-host-redacted] -p [fake-port-redacted]", + }, + metadata: { + provider: "fake-plugin", + sourceTemplateRefRedacted: true, + }, + }); + expect(JSON.stringify(setup.metadata)).not.toContain("source-template-secret"); + + const status = await harness.getInteractiveSetup({ + ...base, + providerLeaseId: setup.providerLeaseId, + includeConnectionPayload: false, + }); + expect(status).toMatchObject({ + status: "waiting_for_user", + connectionPayload: null, + }); + + const captured = await harness.captureTemplate({ + ...base, + providerLeaseId: setup.providerLeaseId, + sourceTemplateRef: "source-template-secret", + previousTemplateRef: "previous-template-secret", + templateLabel: "ignored-by-fake", + }); + expect(captured).toMatchObject({ + templateKind: "snapshot", + templateRef: "fake-template:env-1:setup-1", + metadata: { + provider: "fake-plugin", + sourceTemplateRefRedacted: true, + previousTemplateRefRedacted: true, + promoted: true, + }, + }); + expect(JSON.stringify(captured.metadata)).not.toContain("source-template-secret"); + expect(JSON.stringify(captured.metadata)).not.toContain("previous-template-secret"); + + const deleted = await harness.deleteTemplate({ + ...base, + templateRef: captured.templateRef, + templateKind: captured.templateKind, + }); + expect(deleted).toMatchObject({ + deleted: true, + metadata: { + provider: "fake-plugin", + templateKind: "snapshot", + }, + }); + + const cancelled = await harness.cancelInteractiveSetup({ + ...base, + providerLeaseId: "fake-plugin-setup://env-1/setup-cancel", + reason: "timed_out", + }); + expect(cancelled).toMatchObject({ + status: "timed_out", + metadata: { + provider: "fake-plugin", + found: false, + }, + }); + await harness.destroyLease({ ...base, providerLeaseId: lease.providerLeaseId, @@ -80,10 +167,198 @@ describe("fake sandbox provider plugin", () => { "acquireLease", "realizeWorkspace", "execute", + "startInteractiveSetup", + "getInteractiveSetup", + "captureTemplate", + "deleteTemplate", + "cancelInteractiveSetup", "destroyLease", ]); }); + it("supports deterministic interactive setup, template capture, refresh, and rollback cleanup hooks", async () => { + const definition = plugin.definition; + const harness = createEnvironmentTestHarness({ + manifest, + environmentDriver: { + driverKey: "fake-plugin", + onStartInteractiveSetup: definition.onEnvironmentStartInteractiveSetup, + onGetInteractiveSetup: definition.onEnvironmentGetInteractiveSetup, + onCaptureTemplate: definition.onEnvironmentCaptureTemplate, + onCancelInteractiveSetup: definition.onEnvironmentCancelInteractiveSetup, + onDeleteTemplate: definition.onEnvironmentDeleteTemplate, + }, + }); + const base = { + driverKey: "fake-plugin", + companyId: "company-1", + environmentId: "env-setup", + config: { image: "fake:setup", reuseLease: false }, + }; + + const setup = await harness.startInteractiveSetup({ + ...base, + sessionId: "session-1", + sourceTemplateRef: "fake-template:base", + expiresAt: "2030-01-01T00:00:00.000Z", + }); + + expect(setup).toMatchObject({ + providerLeaseId: "fake-plugin-setup://env-setup/session-1", + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + }, + connectionPayload: { + type: "ssh", + command: "ssh sandbox@[fake-setup-host-redacted] -p [fake-port-redacted]", + }, + metadata: { + provider: "fake-plugin", + image: "fake:setup", + sourceTemplateRefRedacted: true, + redactedConnectionOnly: true, + }, + }); + + const status = await harness.getInteractiveSetup({ + ...base, + providerLeaseId: setup.providerLeaseId, + includeConnectionPayload: false, + }); + expect(status.connectionPayload).toBeNull(); + expect(status.status).toBe("waiting_for_user"); + + const captured = await harness.captureTemplate({ + ...base, + providerLeaseId: setup.providerLeaseId, + sourceTemplateRef: "fake-template:base", + previousTemplateRef: null, + }); + expect(captured).toEqual({ + templateRef: "fake-template:env-setup:session-1", + templateKind: "snapshot", + metadata: { + provider: "fake-plugin", + image: "fake:setup", + sourceTemplateRefRedacted: true, + previousTemplateRefRedacted: false, + setupSessionId: "session-1", + promoted: true, + }, + }); + + const promotedStatus = await harness.getInteractiveSetup({ + ...base, + providerLeaseId: setup.providerLeaseId, + includeConnectionPayload: true, + }); + expect(promotedStatus.status).toBe("promoted"); + expect(promotedStatus.connectionPayload).toBeNull(); + + const refresh = await harness.startInteractiveSetup({ + ...base, + sessionId: "session-2", + sourceTemplateRef: captured.templateRef, + }); + const replacement = await harness.captureTemplate({ + ...base, + providerLeaseId: refresh.providerLeaseId, + sourceTemplateRef: captured.templateRef, + previousTemplateRef: captured.templateRef, + }); + expect(replacement).toMatchObject({ + templateRef: "fake-template:env-setup:session-2", + templateKind: "snapshot", + metadata: { + sourceTemplateRefRedacted: true, + previousTemplateRefRedacted: true, + }, + }); + + await expect(harness.deleteTemplate({ + ...base, + templateRef: replacement.templateRef, + templateKind: replacement.templateKind, + reason: "rollback", + })).resolves.toMatchObject({ + deleted: true, + metadata: { + provider: "fake-plugin", + templateRef: replacement.templateRef, + templateKind: "snapshot", + }, + }); + + const cancelSetup = await harness.startInteractiveSetup({ + ...base, + sessionId: "session-cancel", + sourceTemplateRef: captured.templateRef, + }); + await expect(harness.cancelInteractiveSetup({ + ...base, + providerLeaseId: cancelSetup.providerLeaseId, + reason: "cancelled", + })).resolves.toMatchObject({ + status: "cancelled", + metadata: { + provider: "fake-plugin", + found: true, + }, + }); + + assertEnvironmentEventOrder(harness.environmentEvents, [ + "startInteractiveSetup", + "getInteractiveSetup", + "captureTemplate", + "getInteractiveSetup", + "startInteractiveSetup", + "captureTemplate", + "deleteTemplate", + "startInteractiveSetup", + "cancelInteractiveSetup", + ]); + }); + + it("keeps fake setup and capture payloads free of real-looking secrets, hosts, and IPs", async () => { + const definition = plugin.definition; + const harness = createEnvironmentTestHarness({ + manifest, + environmentDriver: { + driverKey: "fake-plugin", + onStartInteractiveSetup: definition.onEnvironmentStartInteractiveSetup, + onCaptureTemplate: definition.onEnvironmentCaptureTemplate, + }, + }); + const base = { + driverKey: "fake-plugin", + companyId: "company-1", + environmentId: "env-redaction", + config: { image: "fake:redaction", reuseLease: false }, + }; + + const setup = await harness.startInteractiveSetup({ + ...base, + sessionId: "session-redaction", + }); + const captured = await harness.captureTemplate({ + ...base, + providerLeaseId: setup.providerLeaseId, + }); + const serialized = JSON.stringify({ setup, captured, events: harness.environmentEvents }); + + expect(serialized).not.toMatch(/\b(?:\d{1,3}\.){3}\d{1,3}\b/); + expect(serialized).not.toMatch(/\blocalhost\b/i); + expect(serialized).not.toMatch(/BEGIN [A-Z ]*PRIVATE KEY/); + expect(serialized).not.toMatch(/(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret)[":=]/i); + expect(serialized).toContain("[fake-setup-host-redacted]"); + expect(serialized).toContain("[fake-port-redacted]"); + }); + it("does not expose host-only environment variables to executed commands", async () => { const previousSecret = process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET; process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET = "should-not-leak"; diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts b/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts index fcedec9b55..30e7001d5a 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts +++ b/packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts @@ -6,9 +6,17 @@ import { spawn } from "node:child_process"; import { definePlugin } from "@paperclipai/plugin-sdk"; import type { PluginEnvironmentAcquireLeaseParams, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentInteractiveSetupSession, PluginEnvironmentLease, PluginEnvironmentProbeParams, PluginEnvironmentProbeResult, @@ -16,6 +24,7 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentReleaseLeaseParams, PluginEnvironmentResumeLeaseParams, + PluginEnvironmentStartInteractiveSetupParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentValidationResult, } from "@paperclipai/plugin-sdk"; @@ -34,9 +43,33 @@ interface FakeLeaseState { reuseLease: boolean; } +interface FakeSetupState { + providerLeaseId: string; + environmentId: string; + sessionId: string; + image: string; + sourceTemplateRef: string | null; + expiresAt: string | null; + status: "waiting_for_user" | "capturing" | "promoted" | "cancelled" | "timed_out" | "failed"; + capturedTemplateRef: string | null; +} + +interface FakeTemplateState { + templateRef: string; + environmentId: string; + sessionId: string; + image: string; + sourceTemplateRef: string | null; + previousTemplateRef: string | null; + deleted: boolean; +} + const leases = new Map(); +const setupSessions = new Map(); +const templates = new Map(); const DEFAULT_FAKE_SANDBOX_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; const FAKE_SANDBOX_SIGKILL_GRACE_MS = 250; +const REDACTED_FAKE_SSH_COMMAND = "ssh sandbox@[fake-setup-host-redacted] -p [fake-port-redacted]"; function parseConfig(raw: Record): FakeDriverConfig { return { @@ -75,6 +108,76 @@ function leaseMetadata(state: FakeLeaseState) { }; } +function encodeRefPart(value: string): string { + return encodeURIComponent(value).replace(/%/g, "_"); +} + +function buildSetupProviderLeaseId(environmentId: string, sessionId: string): string { + return `fake-plugin-setup://${encodeRefPart(environmentId)}/${encodeRefPart(sessionId)}`; +} + +function buildTemplateRef(environmentId: string, sessionId: string): string { + return `fake-template:${encodeRefPart(environmentId)}:${encodeRefPart(sessionId)}`; +} + +function setupMetadata(state: FakeSetupState): Record { + return { + provider: "fake-plugin", + image: state.image, + sourceTemplateRefRedacted: Boolean(state.sourceTemplateRef), + setupSessionId: state.sessionId, + redactedConnectionOnly: true, + capturedTemplateRefRedacted: Boolean(state.capturedTemplateRef), + }; +} + +function setupConnectionSummary(): PluginEnvironmentInteractiveSetupSession["connectionSummary"] { + return { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + metadata: { + placeholderOnly: true, + }, + }; +} + +function setupConnectionPayload(expiresAt: string | null): PluginEnvironmentInteractiveSetupSession["connectionPayload"] { + return { + type: "ssh", + command: REDACTED_FAKE_SSH_COMMAND, + expiresAt, + metadata: { + placeholderOnly: true, + }, + }; +} + +function presentSetupSession( + state: FakeSetupState, + options: { includeConnectionPayload: boolean }, +): PluginEnvironmentInteractiveSetupSession { + const canConnect = state.status === "waiting_for_user"; + return { + providerLeaseId: state.providerLeaseId, + status: state.status, + connectionSummary: canConnect ? setupConnectionSummary() : null, + connectionPayload: canConnect && options.includeConnectionPayload ? setupConnectionPayload(state.expiresAt) : null, + expiresAt: state.expiresAt, + metadata: setupMetadata(state), + }; +} + +function cancelStatusFromReason( + reason: string | null | undefined, +): Extract { + if (reason === "timed_out") return "timed_out"; + if (reason === "failed") return "failed"; + return "cancelled"; +} + async function removeLease(providerLeaseId: string | null | undefined): Promise { if (!providerLeaseId) return; const state = leases.get(providerLeaseId); @@ -277,6 +380,121 @@ const plugin = definePlugin({ const config = parseConfig(params.config); return await runCommand(params, params.timeoutMs ?? config.timeoutMs); }, + + async onEnvironmentStartInteractiveSetup( + params: PluginEnvironmentStartInteractiveSetupParams, + ): Promise { + const config = parseConfig(params.config); + const providerLeaseId = buildSetupProviderLeaseId(params.environmentId, params.sessionId); + const state = setupSessions.get(providerLeaseId) ?? { + providerLeaseId, + environmentId: params.environmentId, + sessionId: params.sessionId, + image: config.image, + sourceTemplateRef: params.sourceTemplateRef ?? null, + expiresAt: params.expiresAt ?? null, + status: "waiting_for_user" as const, + capturedTemplateRef: null, + }; + setupSessions.set(providerLeaseId, state); + return presentSetupSession(state, { includeConnectionPayload: true }); + }, + + async onEnvironmentGetInteractiveSetup( + params: PluginEnvironmentGetInteractiveSetupParams, + ): Promise { + const state = params.providerLeaseId ? setupSessions.get(params.providerLeaseId) : undefined; + if (!state) { + return { + providerLeaseId: params.providerLeaseId, + status: "missing", + connectionSummary: null, + connectionPayload: null, + metadata: { + provider: "fake-plugin", + found: false, + }, + }; + } + return presentSetupSession(state, { includeConnectionPayload: params.includeConnectionPayload === true }); + }, + + async onEnvironmentCaptureTemplate( + params: PluginEnvironmentCaptureTemplateParams, + ): Promise { + const config = parseConfig(params.config); + const state = params.providerLeaseId ? setupSessions.get(params.providerLeaseId) : undefined; + if (!state) { + throw new Error("Fake setup session not found."); + } + if (state.status === "cancelled" || state.status === "timed_out" || state.status === "failed") { + throw new Error(`Fake setup session cannot be captured from status ${state.status}.`); + } + + state.status = "capturing"; + const templateRef = buildTemplateRef(state.environmentId, state.sessionId); + const template: FakeTemplateState = { + templateRef, + environmentId: state.environmentId, + sessionId: state.sessionId, + image: config.image, + sourceTemplateRef: params.sourceTemplateRef ?? state.sourceTemplateRef, + previousTemplateRef: params.previousTemplateRef ?? null, + deleted: false, + }; + templates.set(templateRef, template); + state.status = "promoted"; + state.capturedTemplateRef = templateRef; + await removeLease(state.providerLeaseId); + + return { + templateRef, + templateKind: "snapshot", + metadata: { + provider: "fake-plugin", + image: template.image, + sourceTemplateRefRedacted: Boolean(template.sourceTemplateRef), + previousTemplateRefRedacted: Boolean(template.previousTemplateRef), + setupSessionId: state.sessionId, + promoted: true, + }, + }; + }, + + async onEnvironmentCancelInteractiveSetup( + params: PluginEnvironmentCancelInteractiveSetupParams, + ): Promise { + const status = cancelStatusFromReason(params.reason); + const state = params.providerLeaseId ? setupSessions.get(params.providerLeaseId) : undefined; + if (state) { + state.status = status; + await removeLease(state.providerLeaseId); + } + return { + status, + metadata: { + provider: "fake-plugin", + found: Boolean(state), + }, + }; + }, + + async onEnvironmentDeleteTemplate( + params: PluginEnvironmentDeleteTemplateParams, + ): Promise { + const template = templates.get(params.templateRef); + if (template) { + template.deleted = true; + } + return { + deleted: Boolean(template), + metadata: { + provider: "fake-plugin", + templateRef: params.templateRef, + templateKind: params.templateKind ?? "snapshot", + }, + }; + }, }); export default plugin; diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index d1ebbc7096..0c1ad491eb 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -24,6 +24,15 @@ const manifest: PaperclipPluginManifestV1 = { description: "Provisions Daytona sandboxes with configurable image or snapshot selection, startup timeouts, and lease reuse.", supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { + field: "snapshot", + unsetFields: ["image"], + }, + supportsTemplateDelete: true, configSchema: { type: "object", properties: { diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index fd8a3d24d9..7ae859228b 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockCreate = vi.hoisted(() => vi.fn()); const mockGet = vi.hoisted(() => vi.fn()); +const mockSnapshotGet = vi.hoisted(() => vi.fn()); +const mockSnapshotDelete = vi.hoisted(() => vi.fn()); const { MockDaytonaNotFoundError, MockDaytonaTimeoutError } = vi.hoisted(() => { class MockDaytonaNotFoundError extends Error {} class MockDaytonaTimeoutError extends Error {} @@ -12,6 +14,10 @@ vi.mock("@daytonaio/sdk", () => ({ Daytona: class MockDaytona { create = mockCreate; get = mockGet; + snapshot = { + get: mockSnapshotGet, + delete: mockSnapshotDelete, + }; constructor(_config?: unknown) {} }, DaytonaNotFoundError: MockDaytonaNotFoundError, @@ -42,6 +48,11 @@ function createMockSandbox(overrides: { recover: vi.fn().mockResolvedValue(undefined), resize: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), + createSshAccess: vi.fn().mockResolvedValue({ + token: "ssh-token-secret", + command: "ssh ssh-token-secret@ssh.app.daytona.io", + }), + _experimental_createSnapshot: vi.fn().mockResolvedValue(undefined), fs: { createFolder: vi.fn().mockResolvedValue(undefined), uploadFile: vi.fn().mockResolvedValue(undefined), @@ -61,6 +72,8 @@ describe("Daytona sandbox provider plugin", () => { beforeEach(() => { mockCreate.mockReset(); mockGet.mockReset(); + mockSnapshotGet.mockReset(); + mockSnapshotDelete.mockReset(); vi.restoreAllMocks(); delete process.env.DAYTONA_API_KEY; }); @@ -72,6 +85,15 @@ describe("Daytona sandbox provider plugin", () => { }); expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function"); expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function"); + expect(plugin.definition.onEnvironmentStartInteractiveSetup).toBeTypeOf("function"); + expect(plugin.definition.onEnvironmentCaptureTemplate).toBeTypeOf("function"); + expect(manifest.environmentDrivers?.[0]).toMatchObject({ + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + supportsTemplateDelete: true, + }); }); it("normalizes config and validates the API key fallback", async () => { @@ -282,6 +304,271 @@ describe("Daytona sandbox provider plugin", () => { ); }); + it("starts an interactive setup sandbox with redacted metadata and one-time SSH payload", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + const session = await plugin.definition.onEnvironmentStartInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + sessionId: "setup-1", + sourceTemplateRef: "existing-secret-snapshot", + sourceTemplateKind: "snapshot", + connectionExpiresInMinutes: 30, + config: { + image: "node:20", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ + snapshot: "existing-secret-snapshot", + labels: { + "paperclip-provider": "daytona", + "paperclip-setup-session-id": "setup-1", + "paperclip-purpose": "interactive_setup", + }, + }); + expect(createParams).not.toHaveProperty("image"); + expect(sandbox.createSshAccess).toHaveBeenCalledWith(30); + expect(session).toMatchObject({ + providerLeaseId: "sandbox-123", + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + username: "token", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + }, + connectionPayload: { + type: "ssh", + command: "ssh ssh-token-secret@ssh.app.daytona.io", + token: "ssh-token-secret", + }, + metadata: { + provider: "daytona", + connectionRedacted: true, + sourceTemplateRefRedacted: true, + }, + }); + expect(JSON.stringify(session?.metadata)).not.toContain("ssh-token-secret"); + expect(JSON.stringify(session?.metadata)).not.toContain("existing-secret-snapshot"); + expect(JSON.stringify(session?.connectionSummary)).not.toContain("ssh-token-secret"); + }); + + it("starts interactive setup from an image source when the environment is image-backed", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentStartInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + sessionId: "setup-image-1", + sourceTemplateRef: "node:20", + sourceTemplateKind: "image", + connectionExpiresInMinutes: 30, + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ + image: "node:20", + labels: { + "paperclip-provider": "daytona", + "paperclip-setup-session-id": "setup-image-1", + "paperclip-purpose": "interactive_setup", + }, + }); + expect(createParams).not.toHaveProperty("snapshot"); + }); + + it("cleans up the setup sandbox if SSH access is unsupported", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + delete (sandbox as { createSshAccess?: unknown }).createSshAccess; + mockCreate.mockResolvedValue(sandbox); + + await expect(plugin.definition.onEnvironmentStartInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + sessionId: "setup-1", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + })).rejects.toThrow("Sandbox.createSshAccess"); + + expect(sandbox.delete).toHaveBeenCalledWith(300); + }); + + it("returns setup status without minting SSH access unless requested", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-setup" }); + mockGet.mockResolvedValue(sandbox); + + const session = await plugin.definition.onEnvironmentGetInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-setup", + includeConnectionPayload: false, + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + expect(sandbox.createSshAccess).not.toHaveBeenCalled(); + expect(session).toMatchObject({ + providerLeaseId: "sandbox-setup", + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + hostRedacted: true, + portRedacted: true, + }, + connectionPayload: null, + }); + }); + + it("returns missing setup status when the Daytona sandbox is gone", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + mockGet.mockRejectedValue(new MockDaytonaNotFoundError("missing")); + + await expect(plugin.definition.onEnvironmentGetInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-missing", + includeConnectionPayload: true, + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + })).resolves.toEqual({ + providerLeaseId: null, + status: "missing", + connectionSummary: null, + connectionPayload: null, + metadata: { + provider: "daytona", + missing: true, + }, + }); + }); + + it("captures a Daytona snapshot from a live setup sandbox with redacted metadata", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-setup" }); + mockGet.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentCaptureTemplate?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-setup", + templateLabel: " Paperclip Env 1 ", + sourceTemplateRef: "source-secret-snapshot", + previousTemplateRef: "previous-secret-snapshot", + timeoutMs: 120000, + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + expect(sandbox._experimental_createSnapshot).toHaveBeenCalledWith("paperclip-env-1", 120); + expect(result).toMatchObject({ + templateKind: "snapshot", + templateRef: "paperclip-env-1", + metadata: { + provider: "daytona", + sandboxId: "sandbox-setup", + sourceTemplateRefRedacted: true, + previousTemplateRefRedacted: true, + }, + }); + expect(JSON.stringify(result?.metadata)).not.toContain("source-secret-snapshot"); + expect(JSON.stringify(result?.metadata)).not.toContain("previous-secret-snapshot"); + }); + + it("cancels an interactive setup sandbox by deleting it", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-setup" }); + mockGet.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentCancelInteractiveSetup?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-setup", + reason: "user_cancelled", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + expect(sandbox.delete).toHaveBeenCalledWith(300); + expect(result).toMatchObject({ + status: "cancelled", + metadata: { + provider: "daytona", + sandboxId: "sandbox-setup", + reason: "user_cancelled", + }, + }); + }); + + it("deletes Daytona snapshot templates through the snapshot service", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const snapshot = { name: "captured-template" }; + mockSnapshotGet.mockResolvedValue(snapshot); + + const result = await plugin.definition.onEnvironmentDeleteTemplate?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + templateRef: "captured-template", + templateKind: "snapshot", + reason: "cleanup", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + expect(mockSnapshotGet).toHaveBeenCalledWith("captured-template"); + expect(mockSnapshotDelete).toHaveBeenCalledWith(snapshot); + expect(result).toEqual({ + deleted: true, + metadata: { + provider: "daytona", + templateKind: "snapshot", + templateRefRedacted: true, + reason: "cleanup", + }, + }); + }); + it("passes configured resources to Daytona for image-based creation", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); @@ -315,6 +602,36 @@ describe("Daytona sandbox provider plugin", () => { expect(sandbox.resize).not.toHaveBeenCalled(); }); + it("drops resource settings for snapshot-backed runtime creation instead of failing", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { + snapshot: "captured-snapshot", + cpu: 4, + memory: 8, + disk: 20, + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(mockCreate).toHaveBeenCalledTimes(1); + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ snapshot: "captured-snapshot" }); + expect(createParams).not.toHaveProperty("resources"); + expect(createParams).not.toHaveProperty("image"); + }); + it("rejects resource settings for snapshot-backed creation", async () => { process.env.DAYTONA_API_KEY = "host-key"; const result = await plugin.definition.onEnvironmentValidateConfig?.({ @@ -337,7 +654,7 @@ describe("Daytona sandbox provider plugin", () => { }); }); - it("rejects resource settings for default snapshot creation before creating a sandbox", async () => { + it("rejects resource settings for default sandbox creation before creating a sandbox", async () => { process.env.DAYTONA_API_KEY = "host-key"; await expect(plugin.definition.onEnvironmentAcquireLease?.({ @@ -355,7 +672,7 @@ describe("Daytona sandbox provider plugin", () => { reuseLease: false, }, })).rejects.toThrow( - "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.", + "Daytona resource settings require image-backed sandbox creation; default sandbox creation cannot override CPU, memory, disk, or GPU.", ); expect(mockCreate).not.toHaveBeenCalled(); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index b004c05195..499a4ddabf 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -12,9 +12,17 @@ import type { import { definePlugin } from "@paperclipai/plugin-sdk"; import type { PluginEnvironmentAcquireLeaseParams, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentInteractiveSetupSession, PluginEnvironmentLease, PluginEnvironmentProbeParams, PluginEnvironmentProbeResult, @@ -22,6 +30,7 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentReleaseLeaseParams, PluginEnvironmentResumeLeaseParams, + PluginEnvironmentStartInteractiveSetupParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentValidationResult, } from "@paperclipai/plugin-sdk"; @@ -50,6 +59,23 @@ type WorkspaceSentinelResult = { result: "written" | "matched" | "missing" | "mismatch" | "skipped"; }; +type DaytonaSshAccess = { + token?: string | null; + command?: string | null; + sshCommand?: string | null; + expiresAt?: string | null; +}; + +type DaytonaInteractiveSandbox = Sandbox & { + createSshAccess?: (expiresInMinutes?: number) => Promise; + _experimental_createSnapshot?: (name: string, timeout?: number) => Promise; +}; + +type DaytonaSnapshotService = { + get?: (name: string) => Promise; + delete?: (snapshot: unknown) => Promise; +}; + const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json"; // Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against @@ -66,6 +92,8 @@ const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-le const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days +const DEFAULT_SSH_ACCESS_MINUTES = 60; +const DAYTONA_SSH_GATEWAY_HOST = "ssh.app.daytona.io"; function parseOptionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -169,10 +197,20 @@ function validateResourceRequest(config: DaytonaDriverConfig): string | null { return "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU."; } +function validateRuntimeResourceRequest(config: DaytonaDriverConfig): string | null { + // A snapshot bakes in its own resource allocation, so resources are dropped at + // create time (see buildCreateParams) rather than failing the run when a custom + // image snapshot is layered over a base config that carries CPU/memory/disk/GPU. + if (!hasResourceRequest(config) || config.image || config.snapshot) return null; + return "Daytona resource settings require image-backed sandbox creation; default sandbox creation cannot override CPU, memory, disk, or GPU."; +} + function buildSandboxLabels(input: { companyId: string; environmentId: string; runId?: string; + setupSessionId?: string; + purpose?: string; reuseLease: boolean; }): Record { return { @@ -181,6 +219,8 @@ function buildSandboxLabels(input: { "paperclip-environment-id": input.environmentId, "paperclip-reuse-lease": input.reuseLease ? "true" : "false", ...(input.runId ? { "paperclip-run-id": input.runId } : {}), + ...(input.setupSessionId ? { "paperclip-setup-session-id": input.setupSessionId } : {}), + ...(input.purpose ? { "paperclip-purpose": input.purpose } : {}), }; } @@ -394,6 +434,126 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +function resolveConnectionExpiresInMinutes(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SSH_ACCESS_MINUTES; + return Math.min(24 * 60, Math.max(1, Math.trunc(value))); +} + +function expiresAtForMinutes(minutes: number): string { + return new Date(Date.now() + minutes * 60_000).toISOString(); +} + +function sanitizeSnapshotName(value: string | null | undefined, fallback: string): string { + const cleaned = (value ?? fallback) + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 96); + return cleaned || fallback; +} + +function withSetupSourceTemplate( + config: DaytonaDriverConfig, + params: Pick, +): DaytonaDriverConfig { + if (!params.sourceTemplateRef) return config; + const sourceKind = params.sourceTemplateKind ?? "snapshot"; + if (sourceKind === "image") { + return { + ...config, + image: params.sourceTemplateRef, + snapshot: null, + }; + } + if (sourceKind !== "snapshot") { + throw new Error(`Daytona interactive setup can start from image or snapshot templates only, not ${sourceKind}.`); + } + return { + ...config, + snapshot: params.sourceTemplateRef, + image: null, + }; +} + +async function createSshConnection( + sandbox: Sandbox, + expiresInMinutes: number, +): Promise> { + const createSshAccess = (sandbox as DaytonaInteractiveSandbox).createSshAccess; + if (typeof createSshAccess !== "function") { + throw new Error( + "Daytona interactive setup requires @daytonaio/sdk Sandbox.createSshAccess support.", + ); + } + + const fallbackExpiresAt = expiresAtForMinutes(expiresInMinutes); + const access = await createSshAccess.call(sandbox, expiresInMinutes); + const token = typeof access.token === "string" && access.token.trim().length > 0 + ? access.token.trim() + : null; + const commandFromAccess = + typeof access.command === "string" && access.command.trim().length > 0 + ? access.command.trim() + : typeof access.sshCommand === "string" && access.sshCommand.trim().length > 0 + ? access.sshCommand.trim() + : null; + const command = commandFromAccess ?? (token ? `ssh ${token}@${DAYTONA_SSH_GATEWAY_HOST}` : null); + if (!command) { + throw new Error("Daytona SSH access did not return a token or SSH command."); + } + const expiresAt = typeof access.expiresAt === "string" && access.expiresAt.trim().length > 0 + ? access.expiresAt.trim() + : fallbackExpiresAt; + + return { + connectionSummary: { + type: "ssh", + username: "token", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + expiresAt, + metadata: { + provider: "daytona", + expiresInMinutes, + }, + }, + connectionPayload: { + type: "ssh", + command, + token, + expiresAt, + metadata: { + provider: "daytona", + sensitive: true, + }, + }, + }; +} + +function interactiveSetupMetadata(input: { + config: DaytonaDriverConfig; + sandbox: Sandbox; + shellCommand: "bash" | "sh"; + remoteCwd: string; + sourceTemplateRef?: string | null; +}) { + return { + provider: "daytona", + sandboxId: input.sandbox.id, + sandboxState: input.sandbox.state ?? null, + shellCommand: input.shellCommand, + imageConfigured: Boolean(input.config.image), + snapshotConfigured: Boolean(input.config.snapshot), + sourceTemplateRefRedacted: Boolean(input.sourceTemplateRef), + target: input.sandbox.target, + timeoutMs: input.config.timeoutMs, + remoteCwd: input.remoteCwd, + connectionRedacted: true, + }; +} + function isValidShellEnvKey(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } @@ -448,10 +608,11 @@ function buildLoginShellScript(input: { } async function createSandbox( - params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams, + params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams | PluginEnvironmentStartInteractiveSetupParams, config: DaytonaDriverConfig, + options: { purpose?: string } = {}, ): Promise { - const resourceRequestError = validateResourceRequest(config); + const resourceRequestError = validateRuntimeResourceRequest(config); if (resourceRequestError) { throw new Error(resourceRequestError); } @@ -460,6 +621,8 @@ async function createSandbox( companyId: params.companyId, environmentId: params.environmentId, runId: "runId" in params ? params.runId : undefined, + setupSessionId: "sessionId" in params ? params.sessionId : undefined, + purpose: options.purpose, reuseLease: config.reuseLease, })); const sandbox = await client.create(createParams, { @@ -778,6 +941,200 @@ const plugin = definePlugin({ }; }, + async onEnvironmentStartInteractiveSetup( + params: PluginEnvironmentStartInteractiveSetupParams, + ): Promise { + const baseConfig = parseDriverConfig(params.config); + const config = withSetupSourceTemplate(baseConfig, params); + const sandbox = await createSandbox(params, config, { purpose: "interactive_setup" }); + try { + const remoteCwd = await resolveSandboxWorkingDirectory(sandbox); + const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); + const connection = await createSshConnection( + sandbox, + resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes), + ); + return { + providerLeaseId: sandbox.id, + status: "waiting_for_user", + expiresAt: params.expiresAt ?? connection.connectionPayload?.expiresAt ?? null, + ...connection, + metadata: interactiveSetupMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + sourceTemplateRef: params.sourceTemplateRef, + }), + }; + } catch (error) { + await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined); + throw error; + } + }, + + async onEnvironmentGetInteractiveSetup( + params: PluginEnvironmentGetInteractiveSetupParams, + ): Promise { + const config = parseDriverConfig(params.config); + if (!params.providerLeaseId) { + return { + providerLeaseId: null, + status: "missing", + connectionSummary: null, + connectionPayload: null, + metadata: { + provider: "daytona", + missing: true, + }, + }; + } + const sandbox = await getSandboxOrNull(config, params.providerLeaseId); + if (!sandbox) { + return { + providerLeaseId: null, + status: "missing", + connectionSummary: null, + connectionPayload: null, + metadata: { + provider: "daytona", + missing: true, + }, + }; + } + + await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs)); + const remoteCwd = await resolveSandboxWorkingDirectory(sandbox); + const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); + const connection = params.includeConnectionPayload === true + ? await createSshConnection(sandbox, resolveConnectionExpiresInMinutes(params.connectionExpiresInMinutes)) + : { + connectionSummary: { + type: "ssh" as const, + username: "token", + hostRedacted: true, + portRedacted: true, + commandRedacted: true, + metadata: { + provider: "daytona", + }, + }, + connectionPayload: null, + }; + + return { + providerLeaseId: sandbox.id, + status: "waiting_for_user", + ...connection, + metadata: interactiveSetupMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + }), + }; + }, + + async onEnvironmentCaptureTemplate( + params: PluginEnvironmentCaptureTemplateParams, + ): Promise { + const config = parseDriverConfig(params.config); + if (!params.providerLeaseId) { + throw new Error("Cannot capture a Daytona template without a setup sandbox lease."); + } + const sandbox = await getSandbox(config, params.providerLeaseId); + const createSnapshot = (sandbox as DaytonaInteractiveSandbox)._experimental_createSnapshot; + if (typeof createSnapshot !== "function") { + throw new Error( + "Daytona template capture requires @daytonaio/sdk Sandbox._experimental_createSnapshot support.", + ); + } + const templateRef = sanitizeSnapshotName( + params.templateLabel, + `paperclip-${params.environmentId}-${randomUUID().slice(0, 8)}`, + ); + const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0 + ? Math.trunc(params.timeoutMs) + : config.timeoutMs; + + await createSnapshot.call(sandbox, templateRef, toTimeoutSeconds(timeoutMs)); + + return { + templateKind: "snapshot", + templateRef, + metadata: { + provider: "daytona", + sandboxId: sandbox.id, + capturedAt: new Date().toISOString(), + sourceTemplateRefRedacted: Boolean(params.sourceTemplateRef), + previousTemplateRefRedacted: Boolean(params.previousTemplateRef), + timeoutMs, + }, + }; + }, + + async onEnvironmentCancelInteractiveSetup( + params: PluginEnvironmentCancelInteractiveSetupParams, + ): Promise { + const config = parseDriverConfig(params.config); + if (!params.providerLeaseId) { + return { + status: "missing", + metadata: { + provider: "daytona", + missing: true, + reason: params.reason ?? null, + }, + }; + } + const sandbox = await getSandboxOrNull(config, params.providerLeaseId); + if (!sandbox) { + return { + status: "missing", + metadata: { + provider: "daytona", + missing: true, + reason: params.reason ?? null, + }, + }; + } + await sandbox.delete(toTimeoutSeconds(config.timeoutMs)); + return { + status: params.reason === "timed_out" ? "timed_out" : "cancelled", + metadata: { + provider: "daytona", + sandboxId: sandbox.id, + reason: params.reason ?? null, + }, + }; + }, + + async onEnvironmentDeleteTemplate( + params: PluginEnvironmentDeleteTemplateParams, + ): Promise { + const templateKind = params.templateKind ?? "snapshot"; + if (templateKind !== "snapshot") { + throw new Error(`Daytona can delete snapshot templates only, not ${templateKind}.`); + } + const config = parseDriverConfig(params.config); + const client = createDaytonaClient(config) as Daytona & { snapshot?: DaytonaSnapshotService }; + const snapshotService = client.snapshot; + if (typeof snapshotService?.get !== "function" || typeof snapshotService.delete !== "function") { + throw new Error("Daytona template deletion requires @daytonaio/sdk snapshot.get/delete support."); + } + const snapshot = await snapshotService.get(params.templateRef); + await snapshotService.delete(snapshot); + return { + deleted: true, + metadata: { + provider: "daytona", + templateKind: "snapshot", + templateRefRedacted: true, + reason: params.reason ?? null, + }, + }; + }, + async onEnvironmentExecute( params: PluginEnvironmentExecuteParams, ): Promise { diff --git a/packages/plugins/sandbox-providers/daytona/tsconfig.json b/packages/plugins/sandbox-providers/daytona/tsconfig.json index 000e3293ef..d1a61f000a 100644 --- a/packages/plugins/sandbox-providers/daytona/tsconfig.json +++ b/packages/plugins/sandbox-providers/daytona/tsconfig.json @@ -4,7 +4,10 @@ "outDir": "dist", "rootDir": "src", "lib": ["ES2023"], - "types": ["node"] + "types": ["node"], + "paths": { + "@paperclipai/plugin-sdk": ["../../sdk/dist/index.d.ts"] + } }, "include": ["src"], "exclude": ["src/**/*.test.ts"] diff --git a/packages/plugins/sandbox-providers/daytona/vitest.config.ts b/packages/plugins/sandbox-providers/daytona/vitest.config.ts index ce36a74267..ba82e20c26 100644 --- a/packages/plugins/sandbox-providers/daytona/vitest.config.ts +++ b/packages/plugins/sandbox-providers/daytona/vitest.config.ts @@ -1,8 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(dirname, "../../../.."); + export default defineConfig({ + root: repoRoot, + resolve: { + alias: { + "@paperclipai/plugin-sdk": path.resolve(dirname, "../../sdk/src/index.ts"), + }, + }, test: { - include: ["src/**/*.test.ts"], + include: ["packages/plugins/sandbox-providers/daytona/src/**/*.test.ts"], environment: "node", }, }); diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index b0e4ecff6e..4359dd65cd 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -53,6 +53,15 @@ import type { PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentStartInteractiveSetupParams, + PluginEnvironmentInteractiveSetupSession, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginEnvironmentLease, PluginEnvironmentProbeParams, PluginEnvironmentProbeResult, @@ -324,6 +333,31 @@ export interface PluginDefinition { onEnvironmentExecute?( params: PluginEnvironmentExecuteParams, ): Promise; + + /** Called to start an interactive setup sandbox and return redacted connection metadata. */ + onEnvironmentStartInteractiveSetup?( + params: PluginEnvironmentStartInteractiveSetupParams, + ): Promise; + + /** Called to read setup status and, when authorized, a one-time connection payload. */ + onEnvironmentGetInteractiveSetup?( + params: PluginEnvironmentGetInteractiveSetupParams, + ): Promise; + + /** Called to capture a reusable provider template from a live setup sandbox. */ + onEnvironmentCaptureTemplate?( + params: PluginEnvironmentCaptureTemplateParams, + ): Promise; + + /** Called to cancel and clean up a setup sandbox without promoting a template. */ + onEnvironmentCancelInteractiveSetup?( + params: PluginEnvironmentCancelInteractiveSetupParams, + ): Promise; + + /** Called for optional best-effort cleanup of a captured provider template. */ + onEnvironmentDeleteTemplate?( + params: PluginEnvironmentDeleteTemplateParams, + ): Promise; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 1339edeafc..3717f48dc3 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -181,6 +181,20 @@ export type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentInteractiveSetupStatus, + PluginEnvironmentInteractiveSetupConnectionType, + PluginEnvironmentTemplateRefKind, + PluginEnvironmentInteractiveSetupConnectionSummary, + PluginEnvironmentInteractiveSetupConnectionPayload, + PluginEnvironmentInteractiveSetupSession, + PluginEnvironmentStartInteractiveSetupParams, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginModalBoundsRequest, PluginRenderCloseEvent, PluginLauncherRenderContextSnapshot, @@ -301,6 +315,7 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginEnvironmentTemplateConfigBinding, PluginManagedAgentDeclaration, PluginManagedAgentResolution, PluginManagedProjectDeclaration, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index c56a0dd23e..61f3e40336 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -647,6 +647,107 @@ export interface PluginEnvironmentExecuteResult { metadata?: Record; } +export type PluginEnvironmentInteractiveSetupStatus = + | "starting" + | "waiting_for_user" + | "capturing" + | "promoted" + | "cancelled" + | "timed_out" + | "failed" + | "missing"; + +export type PluginEnvironmentInteractiveSetupConnectionType = + | "ssh" + | (string & {}); + +export type PluginEnvironmentTemplateRefKind = + | "snapshot" + | "image" + | "provider_template" + | "unknown" + | (string & {}); + +export interface PluginEnvironmentInteractiveSetupConnectionSummary { + type: PluginEnvironmentInteractiveSetupConnectionType; + username?: string | null; + hostRedacted: boolean; + portRedacted: boolean; + commandRedacted?: boolean; + expiresAt?: string | null; + metadata?: Record; +} + +export interface PluginEnvironmentInteractiveSetupConnectionPayload { + type: PluginEnvironmentInteractiveSetupConnectionType; + command?: string | null; + token?: string | null; + expiresAt?: string | null; + metadata?: Record; +} + +export interface PluginEnvironmentInteractiveSetupSession { + providerLeaseId: string | null; + status: PluginEnvironmentInteractiveSetupStatus; + connectionSummary: PluginEnvironmentInteractiveSetupConnectionSummary | null; + connectionPayload?: PluginEnvironmentInteractiveSetupConnectionPayload | null; + expiresAt?: string | null; + metadata?: Record; +} + +export interface PluginEnvironmentStartInteractiveSetupParams extends PluginEnvironmentDriverBaseParams { + sessionId: string; + sourceTemplateRef?: string | null; + sourceTemplateKind?: PluginEnvironmentTemplateRefKind | null; + connectionExpiresInMinutes?: number | null; + expiresAt?: string | null; +} + +export interface PluginEnvironmentGetInteractiveSetupParams extends PluginEnvironmentDriverBaseParams { + providerLeaseId: string | null; + setupMetadata?: Record; + includeConnectionPayload?: boolean; + connectionExpiresInMinutes?: number | null; +} + +export interface PluginEnvironmentCaptureTemplateParams extends PluginEnvironmentDriverBaseParams { + providerLeaseId: string | null; + setupMetadata?: Record; + sourceTemplateRef?: string | null; + previousTemplateRef?: string | null; + templateLabel?: string | null; + timeoutMs?: number | null; +} + +export interface PluginEnvironmentCaptureTemplateResult { + templateRef: string; + templateKind: PluginEnvironmentTemplateRefKind; + metadata?: Record; +} + +export interface PluginEnvironmentCancelInteractiveSetupParams extends PluginEnvironmentDriverBaseParams { + providerLeaseId: string | null; + setupMetadata?: Record; + reason?: string | null; +} + +export interface PluginEnvironmentCancelInteractiveSetupResult { + status: Extract; + metadata?: Record; +} + +export interface PluginEnvironmentDeleteTemplateParams extends PluginEnvironmentDriverBaseParams { + templateRef: string; + templateKind?: PluginEnvironmentTemplateRefKind; + metadata?: Record; + reason?: string | null; +} + +export interface PluginEnvironmentDeleteTemplateResult { + deleted: boolean; + metadata?: Record; +} + // --------------------------------------------------------------------------- // UI launcher / modal host interaction payloads // --------------------------------------------------------------------------- @@ -760,6 +861,26 @@ export interface HostToWorkerMethods { params: PluginEnvironmentExecuteParams, result: PluginEnvironmentExecuteResult, ]; + environmentStartInteractiveSetup: [ + params: PluginEnvironmentStartInteractiveSetupParams, + result: PluginEnvironmentInteractiveSetupSession, + ]; + environmentGetInteractiveSetup: [ + params: PluginEnvironmentGetInteractiveSetupParams, + result: PluginEnvironmentInteractiveSetupSession, + ]; + environmentCaptureTemplate: [ + params: PluginEnvironmentCaptureTemplateParams, + result: PluginEnvironmentCaptureTemplateResult, + ]; + environmentCancelInteractiveSetup: [ + params: PluginEnvironmentCancelInteractiveSetupParams, + result: PluginEnvironmentCancelInteractiveSetupResult, + ]; + environmentDeleteTemplate: [ + params: PluginEnvironmentDeleteTemplateParams, + result: PluginEnvironmentDeleteTemplateResult, + ]; } /** Union of all host→worker method names. */ @@ -794,6 +915,11 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "environmentDestroyLease", "environmentRealizeWorkspace", "environmentExecute", + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCaptureTemplate", + "environmentCancelInteractiveSetup", + "environmentDeleteTemplate", ] as const; // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 2cf2483b1b..769c3471a6 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -57,6 +57,15 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentStartInteractiveSetupParams, + PluginEnvironmentInteractiveSetupSession, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginPerformActionActorContext, PluginPerformActionContext, } from "./protocol.js"; @@ -147,7 +156,12 @@ export interface EnvironmentEventRecord { | "releaseLease" | "destroyLease" | "realizeWorkspace" - | "execute"; + | "execute" + | "startInteractiveSetup" + | "getInteractiveSetup" + | "captureTemplate" + | "cancelInteractiveSetup" + | "deleteTemplate"; driverKey: string; environmentId: string; timestamp: string; @@ -169,6 +183,11 @@ export interface EnvironmentTestHarnessOptions extends TestHarnessOptions { onDestroyLease?: (params: PluginEnvironmentDestroyLeaseParams) => Promise; onRealizeWorkspace?: (params: PluginEnvironmentRealizeWorkspaceParams) => Promise; onExecute?: (params: PluginEnvironmentExecuteParams) => Promise; + onStartInteractiveSetup?: (params: PluginEnvironmentStartInteractiveSetupParams) => Promise; + onGetInteractiveSetup?: (params: PluginEnvironmentGetInteractiveSetupParams) => Promise; + onCaptureTemplate?: (params: PluginEnvironmentCaptureTemplateParams) => Promise; + onCancelInteractiveSetup?: (params: PluginEnvironmentCancelInteractiveSetupParams) => Promise; + onDeleteTemplate?: (params: PluginEnvironmentDeleteTemplateParams) => Promise; }; } @@ -192,6 +211,16 @@ export interface EnvironmentTestHarness extends TestHarness { realizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams): Promise; /** Invoke the environment driver's execute hook. */ execute(params: PluginEnvironmentExecuteParams): Promise; + /** Invoke the environment driver's interactive setup start hook. */ + startInteractiveSetup(params: PluginEnvironmentStartInteractiveSetupParams): Promise; + /** Invoke the environment driver's interactive setup status hook. */ + getInteractiveSetup(params: PluginEnvironmentGetInteractiveSetupParams): Promise; + /** Invoke the environment driver's template capture hook. */ + captureTemplate(params: PluginEnvironmentCaptureTemplateParams): Promise; + /** Invoke the environment driver's interactive setup cancel hook. */ + cancelInteractiveSetup(params: PluginEnvironmentCancelInteractiveSetupParams): Promise; + /** Invoke the environment driver's optional template delete hook. */ + deleteTemplate(params: PluginEnvironmentDeleteTemplateParams): Promise; } // --------------------------------------------------------------------------- @@ -2524,6 +2553,46 @@ export function createEnvironmentTestHarness(options: EnvironmentTestHarnessOpti async execute(params) { return callHook("execute", driver.onExecute, params, "onExecute"); }, + async startInteractiveSetup(params) { + return callHook( + "startInteractiveSetup", + driver.onStartInteractiveSetup, + params, + "onStartInteractiveSetup", + ); + }, + async getInteractiveSetup(params) { + return callHook( + "getInteractiveSetup", + driver.onGetInteractiveSetup, + params, + "onGetInteractiveSetup", + ); + }, + async captureTemplate(params) { + return callHook( + "captureTemplate", + driver.onCaptureTemplate, + params, + "onCaptureTemplate", + ); + }, + async cancelInteractiveSetup(params) { + return callHook( + "cancelInteractiveSetup", + driver.onCancelInteractiveSetup, + params, + "onCancelInteractiveSetup", + ); + }, + async deleteTemplate(params) { + return callHook( + "deleteTemplate", + driver.onDeleteTemplate, + params, + "onDeleteTemplate", + ); + }, }; return envHarness; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index e449252cc6..efa84423b8 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -59,6 +59,7 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginEnvironmentTemplateConfigBinding, PluginManagedAgentDeclaration, PluginManagedAgentResolution, PluginManagedProjectDeclaration, diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index cded95a82d..d63d2f7c4d 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -93,6 +93,11 @@ import type { PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentProbeParams, + PluginEnvironmentStartInteractiveSetupParams, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentDeleteTemplateParams, PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, @@ -1385,6 +1390,21 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "environmentExecute": return handleEnvironmentExecute(params as PluginEnvironmentExecuteParams); + case "environmentStartInteractiveSetup": + return handleEnvironmentStartInteractiveSetup(params as PluginEnvironmentStartInteractiveSetupParams); + + case "environmentGetInteractiveSetup": + return handleEnvironmentGetInteractiveSetup(params as PluginEnvironmentGetInteractiveSetupParams); + + case "environmentCaptureTemplate": + return handleEnvironmentCaptureTemplate(params as PluginEnvironmentCaptureTemplateParams); + + case "environmentCancelInteractiveSetup": + return handleEnvironmentCancelInteractiveSetup(params as PluginEnvironmentCancelInteractiveSetupParams); + + case "environmentDeleteTemplate": + return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams); + default: throw Object.assign( new Error(`Unknown method: ${method}`), @@ -1429,6 +1449,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onEnvironmentDestroyLease) supportedMethods.push("environmentDestroyLease"); if (plugin.definition.onEnvironmentRealizeWorkspace) supportedMethods.push("environmentRealizeWorkspace"); if (plugin.definition.onEnvironmentExecute) supportedMethods.push("environmentExecute"); + if (plugin.definition.onEnvironmentStartInteractiveSetup) supportedMethods.push("environmentStartInteractiveSetup"); + if (plugin.definition.onEnvironmentGetInteractiveSetup) supportedMethods.push("environmentGetInteractiveSetup"); + if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate"); + if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup"); + if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate"); return { ok: true, supportedMethods }; } @@ -1686,6 +1711,41 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return plugin.definition.onEnvironmentExecute(params); } + async function handleEnvironmentStartInteractiveSetup(params: PluginEnvironmentStartInteractiveSetupParams) { + if (!plugin.definition.onEnvironmentStartInteractiveSetup) { + throw methodNotImplemented("environmentStartInteractiveSetup"); + } + return plugin.definition.onEnvironmentStartInteractiveSetup(params); + } + + async function handleEnvironmentGetInteractiveSetup(params: PluginEnvironmentGetInteractiveSetupParams) { + if (!plugin.definition.onEnvironmentGetInteractiveSetup) { + throw methodNotImplemented("environmentGetInteractiveSetup"); + } + return plugin.definition.onEnvironmentGetInteractiveSetup(params); + } + + async function handleEnvironmentCaptureTemplate(params: PluginEnvironmentCaptureTemplateParams) { + if (!plugin.definition.onEnvironmentCaptureTemplate) { + throw methodNotImplemented("environmentCaptureTemplate"); + } + return plugin.definition.onEnvironmentCaptureTemplate(params); + } + + async function handleEnvironmentCancelInteractiveSetup(params: PluginEnvironmentCancelInteractiveSetupParams) { + if (!plugin.definition.onEnvironmentCancelInteractiveSetup) { + throw methodNotImplemented("environmentCancelInteractiveSetup"); + } + return plugin.definition.onEnvironmentCancelInteractiveSetup(params); + } + + async function handleEnvironmentDeleteTemplate(params: PluginEnvironmentDeleteTemplateParams) { + if (!plugin.definition.onEnvironmentDeleteTemplate) { + throw methodNotImplemented("environmentDeleteTemplate"); + } + return plugin.definition.onEnvironmentDeleteTemplate(params); + } + // ----------------------------------------------------------------------- // Event filter helper // ----------------------------------------------------------------------- diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 948c752d1e..5ed37e0ea1 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -5,6 +5,14 @@ export const API = { companies: `${API_PREFIX}/companies`, agents: `${API_PREFIX}/agents`, projects: `${API_PREFIX}/projects`, + environments: `${API_PREFIX}/environments`, + environmentCustomImageTemplate: `${API_PREFIX}/environments/:environmentId/custom-image-template`, + environmentCustomImageTemplateDisable: `${API_PREFIX}/environments/:environmentId/custom-image-template`, + environmentCustomImageTemplateRollback: `${API_PREFIX}/environments/:environmentId/custom-image-template/rollback`, + environmentCustomImageSetupSessions: `${API_PREFIX}/environments/:environmentId/custom-image-setup-sessions`, + environmentCustomImageSetupSession: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId`, + environmentCustomImageSetupSessionFinish: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/finish`, + environmentCustomImageSetupSessionCancel: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/cancel`, issues: `${API_PREFIX}/issues`, issueWatchdog: `${API_PREFIX}/issues/:issueId/watchdog`, issueTreeControl: `${API_PREFIX}/issues/:issueId/tree-control`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 942d322369..8e0461ecdd 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -489,6 +489,42 @@ export type EnvironmentLeasePolicy = (typeof ENVIRONMENT_LEASE_POLICIES)[number] export const ENVIRONMENT_LEASE_CLEANUP_STATUSES = ["pending", "success", "failed"] as const; export type EnvironmentLeaseCleanupStatus = (typeof ENVIRONMENT_LEASE_CLEANUP_STATUSES)[number]; +export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS = [ + "snapshot", + "image", + "provider_template", + "unknown", +] as const; +export type EnvironmentCustomImageTemplateKind = (typeof ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS)[number]; + +export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES = [ + "active", + "superseded", + "revoked", + "failed", +] as const; +export type EnvironmentCustomImageTemplateStatus = (typeof ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES)[number]; + +export const ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES = [ + "starting", + "waiting_for_user", + "capturing", + "promoted", + "cancelled", + "timed_out", + "failed", +] as const; +export type EnvironmentCustomImageSetupSessionStatus = + (typeof ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES)[number]; + +export const ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES = [ + "ssh", + "browser_terminal", + "unknown", +] as const; +export type EnvironmentCustomImageSetupConnectionType = + (typeof ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES)[number]; + export const ROUTINE_STATUSES = ["active", "paused", "archived"] as const; export type RoutineStatus = (typeof ROUTINE_STATUSES)[number]; diff --git a/packages/shared/src/environment-custom-images.test.ts b/packages/shared/src/environment-custom-images.test.ts new file mode 100644 index 0000000000..add76996c9 --- /dev/null +++ b/packages/shared/src/environment-custom-images.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + redactEnvironmentCustomImageSetupSession, + redactEnvironmentCustomImageTemplate, +} from "./environment-custom-images.js"; +import { + environmentCustomImageSetupConnectionSummarySchema, + environmentCustomImageSetupSessionSchema, + environmentCustomImageTemplateSchema, + startEnvironmentCustomImageSetupSessionSchema, +} from "./validators/environment-custom-images.js"; + +const companyId = "11111111-1111-4111-8111-111111111111"; +const environmentId = "22222222-2222-4222-8222-222222222222"; +const templateId = "33333333-3333-4333-8333-333333333333"; +const sessionId = "44444444-4444-4444-8444-444444444444"; + +describe("environment customImage validators", () => { + it("requires company and environment scope on templates and setup sessions", () => { + const template = environmentCustomImageTemplateSchema.parse({ + id: templateId, + companyId, + environmentId, + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-ref", + sourceTemplateRef: null, + sourceEnvironmentConfigFingerprint: "sha256:abc", + status: "active", + createdByUserId: "user-1", + createdByAgentId: null, + capturedAt: "2026-06-25T12:00:00.000Z", + lastUsedAt: null, + supersededByTemplateId: null, + metadata: { adapterType: "codex_local" }, + createdAt: "2026-06-25T12:00:00.000Z", + updatedAt: "2026-06-25T12:00:00.000Z", + }); + + expect(template.companyId).toBe(companyId); + expect(template.environmentId).toBe(environmentId); + + const session = environmentCustomImageSetupSessionSchema.parse({ + id: sessionId, + companyId, + environmentId, + templateId, + promotedTemplateId: null, + provider: "daytona", + providerLeaseId: "lease-1", + environmentLeaseId: null, + status: "waiting_for_user", + startedByUserId: "user-1", + startedByAgentId: null, + baseTemplateRef: "snapshot-ref", + expiresAt: "2026-06-25T13:00:00.000Z", + finishedAt: null, + failureReason: null, + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }, + connectionSecretRef: "secret-ref", + metadata: null, + createdAt: "2026-06-25T12:00:00.000Z", + updatedAt: "2026-06-25T12:00:00.000Z", + }); + + expect(session.companyId).toBe(companyId); + expect(session.environmentId).toBe(environmentId); + }); + + it("validates setup-session requests and redacted connection summaries", () => { + expect(startEnvironmentCustomImageSetupSessionSchema.parse({ templateId })).toEqual({ templateId }); + expect(() => startEnvironmentCustomImageSetupSessionSchema.parse({ ttlSeconds: 30 })).toThrow(); + + expect( + environmentCustomImageSetupConnectionSummarySchema.parse({ + type: "ssh", + username: "sandbox", + }), + ).toMatchObject({ + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }); + + expect(() => environmentCustomImageSetupConnectionSummarySchema.parse({ + type: "ssh", + hostRedacted: false, + })).toThrow(); + }); +}); + +describe("environment customImage redaction", () => { + it("redacts template refs and secret-like provider metadata", () => { + const redacted = redactEnvironmentCustomImageTemplate({ + templateRef: "daytona-snapshot-secret-ref", + sourceTemplateRef: "base-image-secret-ref", + metadata: { + safeLabel: "codex template", + apiToken: "token-value", + nested: { + host: "203.0.113.10", + safe: "kept", + }, + }, + }); + + expect(redacted.templateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); + expect(redacted.sourceTemplateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); + expect(redacted.metadata).toEqual({ + safeLabel: "codex template", + apiToken: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + nested: { + host: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + safe: "kept", + }, + }); + }); + + it("redacts setup lease and connection material while preserving redaction flags", () => { + const redacted = redactEnvironmentCustomImageSetupSession({ + providerLeaseId: "lease-secret", + baseTemplateRef: "snapshot-secret", + connectionSecretRef: "secret-ref", + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + instructions: "ssh sandbox@203.0.113.10", + }, + metadata: { + connectUrl: "https://internal.example.test/session", + }, + }); + + expect(redacted.providerLeaseId).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); + expect(redacted.baseTemplateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); + expect(redacted.connectionSecretRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); + expect(redacted.connectionSummary).toEqual({ + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + instructions: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + }); + expect(redacted.metadata).toEqual({ + connectUrl: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + }); + }); +}); diff --git a/packages/shared/src/environment-custom-images.ts b/packages/shared/src/environment-custom-images.ts new file mode 100644 index 0000000000..be4cca5f61 --- /dev/null +++ b/packages/shared/src/environment-custom-images.ts @@ -0,0 +1,107 @@ +export const REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE = "[redacted]"; + +const SENSITIVE_KEY_PATTERNS = [ + /auth/i, + /credential/i, + /host/i, + /ip/i, + /^key$/i, + /lease/i, + /password/i, + /private.?key/i, + /sandbox.?id/i, + /secret/i, + /template.?ref/i, + /token/i, + /url/i, +]; + +const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; +const SSH_COMMAND_PATTERN = /\bssh\s+[-\w@.:/]+\b/i; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSensitiveKey(key: string): boolean { + if (key.endsWith("Redacted")) return false; + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +function redactSensitivePrimitive(value: unknown): unknown { + if (typeof value !== "string") return value; + if (IPV4_PATTERN.test(value) || SSH_COMMAND_PATTERN.test(value)) { + return REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE; + } + return value; +} + +export function redactEnvironmentCustomImageValue(value: T): T { + if (Array.isArray(value)) { + return value.map((entry) => redactEnvironmentCustomImageValue(entry)) as T; + } + + if (!isPlainRecord(value)) { + return redactSensitivePrimitive(value) as T; + } + + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = isSensitiveKey(key) + ? REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE + : redactEnvironmentCustomImageValue(entry); + } + return redacted as T; +} + +export interface EnvironmentCustomImageTemplateRedactionInput { + templateRef?: string | null; + sourceTemplateRef?: string | null; + metadata?: Record | null; +} + +export function redactEnvironmentCustomImageTemplate< + T extends EnvironmentCustomImageTemplateRedactionInput, +>(template: T): T { + return { + ...template, + templateRef: template.templateRef == null ? template.templateRef : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + sourceTemplateRef: template.sourceTemplateRef == null + ? template.sourceTemplateRef + : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + metadata: template.metadata == null + ? template.metadata + : redactEnvironmentCustomImageValue(template.metadata), + }; +} + +export interface EnvironmentCustomImageSetupSessionRedactionInput { + providerLeaseId?: string | null; + baseTemplateRef?: string | null; + connectionSecretRef?: string | null; + connectionSummary?: Record | null; + metadata?: Record | null; +} + +export function redactEnvironmentCustomImageSetupSession< + T extends EnvironmentCustomImageSetupSessionRedactionInput, +>(session: T): T { + return { + ...session, + providerLeaseId: session.providerLeaseId == null + ? session.providerLeaseId + : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + baseTemplateRef: session.baseTemplateRef == null + ? session.baseTemplateRef + : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + connectionSecretRef: session.connectionSecretRef == null + ? session.connectionSecretRef + : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + connectionSummary: session.connectionSummary == null + ? session.connectionSummary + : redactEnvironmentCustomImageValue(session.connectionSummary), + metadata: session.metadata == null + ? session.metadata + : redactEnvironmentCustomImageValue(session.metadata), + }; +} diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index f63ad70790..69a2458ecc 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -1,6 +1,6 @@ import type { AgentAdapterType, EnvironmentDriver } from "./constants.js"; import type { SandboxEnvironmentProvider } from "./types/environment.js"; -import type { JsonSchema } from "./types/plugin.js"; +import type { JsonSchema, PluginEnvironmentTemplateConfigBinding } from "./types/plugin.js"; export type EnvironmentSupportStatus = "supported" | "unsupported"; @@ -16,6 +16,12 @@ export interface EnvironmentProviderCapability { supportsUnsavedProbe: boolean; supportsRunExecution: boolean; supportsReusableLeases: boolean; + supportsInteractiveSetup: boolean; + interactiveSetupConnectionTypes: string[]; + supportsTemplateCapture: boolean; + templateRefKind?: string; + templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; + supportsTemplateDelete: boolean; displayName?: string; description?: string; source?: "builtin" | "plugin"; @@ -117,6 +123,10 @@ export function getEnvironmentCapabilities( supportsUnsavedProbe: true, supportsRunExecution: false, supportsReusableLeases: true, + supportsInteractiveSetup: false, + interactiveSetupConnectionTypes: [], + supportsTemplateCapture: false, + supportsTemplateDelete: false, displayName: "Fake", source: "builtin", }, @@ -128,6 +138,12 @@ export function getEnvironmentCapabilities( supportsUnsavedProbe: capability.supportsUnsavedProbe ?? true, supportsRunExecution: capability.supportsRunExecution ?? true, supportsReusableLeases: capability.supportsReusableLeases ?? true, + supportsInteractiveSetup: capability.supportsInteractiveSetup ?? false, + interactiveSetupConnectionTypes: capability.interactiveSetupConnectionTypes ?? [], + supportsTemplateCapture: capability.supportsTemplateCapture ?? false, + templateRefKind: capability.templateRefKind, + templateConfigBinding: capability.templateConfigBinding, + supportsTemplateDelete: capability.supportsTemplateDelete ?? false, displayName: capability.displayName, description: capability.description, source: capability.source ?? "plugin", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ea2022a3da..b61d9c57de 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -164,6 +164,10 @@ export { ENVIRONMENT_LEASE_STATUSES, ENVIRONMENT_LEASE_POLICIES, ENVIRONMENT_LEASE_CLEANUP_STATUSES, + ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS, + ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES, + ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES, + ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES, ROUTINE_STATUSES, ROUTINE_CONCURRENCY_POLICIES, ROUTINE_CATCH_UP_POLICIES, @@ -298,6 +302,10 @@ export { type EnvironmentLeaseStatus, type EnvironmentLeasePolicy, type EnvironmentLeaseCleanupStatus, + type EnvironmentCustomImageTemplateKind, + type EnvironmentCustomImageTemplateStatus, + type EnvironmentCustomImageSetupSessionStatus, + type EnvironmentCustomImageSetupConnectionType, type RoutineStatus, type RoutineConcurrencyPolicy, type RoutineCatchUpPolicy, @@ -375,6 +383,15 @@ export { validateConfiguredBindMode, } from "./network-bind.js"; +export { + REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + redactEnvironmentCustomImageSetupSession, + redactEnvironmentCustomImageTemplate, + redactEnvironmentCustomImageValue, + type EnvironmentCustomImageSetupSessionRedactionInput, + type EnvironmentCustomImageTemplateRedactionInput, +} from "./environment-custom-images.js"; + export type { Company, Environment, @@ -846,6 +863,7 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginEnvironmentTemplateConfigBinding, PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, @@ -1581,3 +1599,22 @@ export { adapterRegistrySchema, type AdapterRegistryEntryParsed, } from "./validators/adapter-registry.js"; + +export { + environmentCustomImageTemplateKindSchema, + environmentCustomImageTemplateStatusSchema, + environmentCustomImageSetupSessionStatusSchema, + environmentCustomImageSetupConnectionTypeSchema, + environmentCustomImageSetupConnectionSummarySchema, + environmentCustomImageTemplateSchema, + environmentCustomImageSetupSessionSchema, + startEnvironmentCustomImageSetupSessionSchema, + finishEnvironmentCustomImageSetupSessionSchema, + cancelEnvironmentCustomImageSetupSessionSchema, + type EnvironmentCustomImageSetupConnectionSummary, + type EnvironmentCustomImageTemplate, + type EnvironmentCustomImageSetupSession, + type StartEnvironmentCustomImageSetupSession, + type FinishEnvironmentCustomImageSetupSession, + type CancelEnvironmentCustomImageSetupSession, +} from "./validators/environment-custom-images.js"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2716cbdcff..13f972d940 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -550,6 +550,7 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginEnvironmentTemplateConfigBinding, PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 20ea2c2ece..d614061c4f 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -123,6 +123,13 @@ export interface PluginToolDeclaration { * * Requires the `environment.drivers.register` capability. */ +export interface PluginEnvironmentTemplateConfigBinding { + /** Top-level provider config field that should receive the captured template ref. */ + field: string; + /** Top-level provider config fields to remove when the captured template ref is applied. */ + unsetFields?: string[]; +} + export interface PluginEnvironmentDriverDeclaration { /** Stable driver key, unique within the plugin. Namespaced by plugin ID at runtime. */ driverKey: string; @@ -144,6 +151,21 @@ export interface PluginEnvironmentDriverDeclaration { * behavior even if their config schema exposes a reuse-like setting. */ supportsReusableLeases?: boolean; + /** Provider can keep a temporary setup sandbox alive for user-driven sandbox customization and capture. */ + supportsInteractiveSetup?: boolean; + /** Connection types the setup sandbox can expose. Initially `ssh`; providers may add custom values. */ + interactiveSetupConnectionTypes?: string[]; + /** Provider can capture a reusable template from a live setup sandbox. */ + supportsTemplateCapture?: boolean; + /** Kind of template reference returned by the provider's capture hook. */ + templateRefKind?: "snapshot" | "image" | "provider_template" | "unknown" | (string & {}); + /** + * How Paperclip should apply a captured template ref back into this provider's + * runtime config. Omit to use the standard key for `templateRefKind`. + */ + templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; + /** Provider supports best-effort deletion/cleanup of captured templates. */ + supportsTemplateDelete?: boolean; /** JSON Schema describing the driver's provider-specific configuration. */ configSchema: JsonSchema; } diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts new file mode 100644 index 0000000000..523f1d2155 --- /dev/null +++ b/packages/shared/src/validators/environment-custom-images.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { + ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES, + ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES, + ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS, + ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES, +} from "../constants.js"; + +const isoDateTime = z.union([z.date(), z.string().datetime()]); +const providerKeySchema = z.string().min(1).max(200); +const optionalRecordSchema = z.record(z.string(), z.unknown()).optional().nullable(); + +export const environmentCustomImageTemplateKindSchema = z.enum(ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS); +export const environmentCustomImageTemplateStatusSchema = z.enum(ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES); +export const environmentCustomImageSetupSessionStatusSchema = z.enum( + ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES, +); +export const environmentCustomImageSetupConnectionTypeSchema = z.enum( + ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES, +); + +export const environmentCustomImageSetupConnectionSummarySchema = z.object({ + type: environmentCustomImageSetupConnectionTypeSchema, + username: z.string().min(1).max(200).optional().nullable(), + hostRedacted: z.literal(true).optional().default(true), + portRedacted: z.literal(true).optional().default(true), + label: z.string().min(1).max(200).optional().nullable(), + instructions: z.string().min(1).max(1000).optional().nullable(), +}).strict(); +export type EnvironmentCustomImageSetupConnectionSummary = + z.infer; + +export const environmentCustomImageTemplateSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + environmentId: z.string().uuid(), + provider: providerKeySchema, + templateKind: environmentCustomImageTemplateKindSchema, + templateRef: z.string().min(1).nullable(), + sourceTemplateRef: z.string().min(1).nullable(), + sourceEnvironmentConfigFingerprint: z.string().min(1).nullable(), + status: environmentCustomImageTemplateStatusSchema, + createdByUserId: z.string().min(1).nullable(), + createdByAgentId: z.string().uuid().nullable(), + capturedAt: isoDateTime.nullable(), + lastUsedAt: isoDateTime.nullable(), + supersededByTemplateId: z.string().uuid().nullable(), + metadata: optionalRecordSchema, + createdAt: isoDateTime, + updatedAt: isoDateTime, +}).strict(); +export type EnvironmentCustomImageTemplate = + z.infer; + +export const environmentCustomImageSetupSessionSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + environmentId: z.string().uuid(), + templateId: z.string().uuid().nullable(), + promotedTemplateId: z.string().uuid().nullable(), + provider: providerKeySchema, + providerLeaseId: z.string().min(1).nullable(), + environmentLeaseId: z.string().uuid().nullable(), + status: environmentCustomImageSetupSessionStatusSchema, + startedByUserId: z.string().min(1).nullable(), + startedByAgentId: z.string().uuid().nullable(), + baseTemplateRef: z.string().min(1).nullable(), + expiresAt: isoDateTime.nullable(), + finishedAt: isoDateTime.nullable(), + failureReason: z.string().min(1).nullable(), + connectionSummary: environmentCustomImageSetupConnectionSummarySchema.nullable(), + connectionSecretRef: z.string().min(1).nullable(), + metadata: optionalRecordSchema, + createdAt: isoDateTime, + updatedAt: isoDateTime, +}).strict(); +export type EnvironmentCustomImageSetupSession = + z.infer; + +export const startEnvironmentCustomImageSetupSessionSchema = z.object({ + templateId: z.string().uuid().optional().nullable(), + ttlSeconds: z.number().int().min(60).max(24 * 60 * 60).optional(), +}).strict(); +export type StartEnvironmentCustomImageSetupSession = + z.infer; + +export const finishEnvironmentCustomImageSetupSessionSchema = z.object({ + metadata: z.record(z.string(), z.unknown()).optional(), +}).strict(); +export type FinishEnvironmentCustomImageSetupSession = + z.infer; + +export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({ + reason: z.string().min(1).max(1000).optional(), +}).strict(); +export type CancelEnvironmentCustomImageSetupSession = + z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9e3fd62649..6bf6f3e4de 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -41,6 +41,24 @@ export { type UpdateEnvironment, type ProbeEnvironmentConfig, } from "./environment.js"; +export { + environmentCustomImageTemplateKindSchema, + environmentCustomImageTemplateStatusSchema, + environmentCustomImageSetupSessionStatusSchema, + environmentCustomImageSetupConnectionTypeSchema, + environmentCustomImageSetupConnectionSummarySchema, + environmentCustomImageTemplateSchema, + environmentCustomImageSetupSessionSchema, + startEnvironmentCustomImageSetupSessionSchema, + finishEnvironmentCustomImageSetupSessionSchema, + cancelEnvironmentCustomImageSetupSessionSchema, + type EnvironmentCustomImageSetupConnectionSummary, + type EnvironmentCustomImageTemplate, + type EnvironmentCustomImageSetupSession, + type StartEnvironmentCustomImageSetupSession, + type FinishEnvironmentCustomImageSetupSession, + type CancelEnvironmentCustomImageSetupSession, +} from "./environment-custom-images.js"; export { feedbackDataSharingPreferenceSchema, feedbackTargetTypeSchema, diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index dc06d4e929..8b210885b0 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -37,6 +37,66 @@ describe("plugin manifest validators", () => { expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]); }); + + it("accepts sandbox provider template config bindings", () => { + const parsed = pluginManifestV1Schema.parse({ + id: "paperclip.template-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Template Provider", + description: "Sandbox provider with captured template config binding.", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "template-provider", + kind: "sandbox_provider", + displayName: "Template Provider", + supportsTemplateCapture: true, + templateRefKind: "provider_template", + templateConfigBinding: { + field: "templateId", + unsetFields: ["image"], + }, + configSchema: { type: "object" }, + }, + ], + }); + + expect(parsed.environmentDrivers?.[0]?.templateConfigBinding).toEqual({ + field: "templateId", + unsetFields: ["image"], + }); + }); + + it("rejects template config bindings that replace provider identity", () => { + const parsed = pluginManifestV1Schema.safeParse({ + id: "paperclip.bad-template-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Bad Template Provider", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "bad-template-provider", + kind: "sandbox_provider", + displayName: "Bad Template Provider", + templateConfigBinding: { + field: "provider", + }, + configSchema: { type: "object" }, + }, + ], + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("provider key"))).toBe(true); + }); }); describe("plugin managed routine validators", () => { diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 3462d9d656..91535b3b88 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -117,6 +117,42 @@ export const pluginToolDeclarationSchema = z.object({ parametersSchema: jsonSchemaSchema, }); +const pluginEnvironmentTemplateConfigFieldSchema = z.string() + .min(1) + .max(100) + .regex( + /^[A-Za-z_][A-Za-z0-9_-]*$/, + "Template config binding fields must be top-level config keys using letters, digits, underscores, or hyphens", + ) + .refine((value) => value !== "provider", { + message: "Template config binding must not replace the sandbox provider key", + }); + +export const pluginEnvironmentTemplateConfigBindingSchema = z.object({ + field: pluginEnvironmentTemplateConfigFieldSchema, + unsetFields: z.array(pluginEnvironmentTemplateConfigFieldSchema).max(20).optional(), +}).strict().superRefine((value, ctx) => { + const unsetFields = value.unsetFields ?? []; + const seen = new Set(); + for (const [index, field] of unsetFields.entries()) { + if (field === value.field) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Template config binding cannot unset the same field it sets", + path: ["unsetFields", index], + }); + } + if (seen.has(field)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Template config binding unsetFields must be unique", + path: ["unsetFields", index], + }); + } + seen.add(field); + } +}); + export const pluginEnvironmentDriverDeclarationSchema = z.object({ driverKey: z.string().min(1).regex( /^[a-z0-9][a-z0-9._-]*$/, @@ -126,6 +162,12 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ displayName: z.string().min(1).max(100), description: z.string().max(500).optional(), supportsReusableLeases: z.boolean().optional(), + supportsInteractiveSetup: z.boolean().optional(), + interactiveSetupConnectionTypes: z.array(z.string().min(1).max(100)).max(10).optional(), + supportsTemplateCapture: z.boolean().optional(), + templateRefKind: z.string().min(1).max(100).optional(), + templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(), + supportsTemplateDelete: z.boolean().optional(), configSchema: jsonSchemaSchema, }); diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts new file mode 100644 index 0000000000..3d90806ea9 --- /dev/null +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -0,0 +1,422 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { environmentRoutes } from "../routes/environments.js"; + +const now = new Date("2026-06-25T20:00:00.000Z"); + +const mockIssueService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); + +const mockProjectService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + listCompanyIds: vi.fn(), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + listLeases: vi.fn(), + getLeaseById: vi.fn(), +})); + +const mockEnvironmentCustomImageService = vi.hoisted(() => ({ + getOverview: vi.fn(), + getActiveTemplate: vi.fn(), + getSessionById: vi.fn(), + startSetupSession: vi.fn(), + refreshSetupSession: vi.fn(), + finishSetupSession: vi.fn(), + cancelSetupSession: vi.fn(), + rollbackTemplate: vi.fn(), + disableTemplate: vi.fn(), + cleanupExpiredSetupSessions: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + clearEnvironmentSelection: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + create: vi.fn(), + normalizeEnvBindingsForPersistence: vi.fn(), + listBindingCompanyIdsForTarget: vi.fn(), + resolveSecretValueForEphemeralAccess: vi.fn(), + syncEnvBindingsForTarget: vi.fn(), + syncSecretRefsForTarget: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + issueService: () => mockIssueService, + projectService: () => mockProjectService, + instanceSettingsService: () => mockInstanceSettingsService, + environmentService: () => mockEnvironmentService, + environmentCustomImageService: () => mockEnvironmentCustomImageService, + logActivity: mockLogActivity, +})); + +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); + +vi.mock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, +})); + +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); + +vi.mock("../services/environment-probe.js", () => ({ + probeEnvironment: vi.fn(), +})); + +vi.mock("../services/plugin-environment-driver.js", () => ({ + listReadyPluginEnvironmentDrivers: vi.fn(async () => []), + resolvePluginSandboxProviderDriverByKey: vi.fn(async () => null), + validatePluginEnvironmentDriverConfig: vi.fn(async ({ config }) => config), + validatePluginSandboxProviderConfig: vi.fn(async ({ provider, config }) => ({ + normalizedConfig: config, + pluginId: `plugin-${provider}`, + pluginKey: `plugin.${provider}`, + driver: { + driverKey: provider, + kind: "sandbox_provider", + displayName: provider, + configSchema: { type: "object" }, + }, + })), +})); + +function createEnvironment(overrides: Record = {}) { + return { + id: "env-1", + name: "Daytona", + description: null, + driver: "sandbox", + status: "active", + config: { + provider: "daytona", + snapshot: "base-snapshot", + reuseLease: false, + }, + envVars: {}, + metadata: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function createTemplate(overrides: Record = {}) { + return { + id: "template-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-secret-ref", + sourceTemplateRef: "base-snapshot-secret", + sourceEnvironmentConfigFingerprint: "sha256:base", + status: "active", + createdByUserId: "user-1", + createdByAgentId: null, + capturedAt: now, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: { safeLabel: "codex" }, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function createSession(overrides: Record = {}) { + return { + id: "session-1", + companyId: "company-1", + environmentId: "env-1", + templateId: "template-1", + promotedTemplateId: null, + provider: "daytona", + providerLeaseId: "lease-secret", + environmentLeaseId: null, + status: "waiting_for_user", + startedByUserId: "user-1", + startedByAgentId: null, + baseTemplateRef: "snapshot-secret-ref", + expiresAt: new Date("2026-06-25T21:00:00.000Z"), + finishedAt: null, + failureReason: null, + connectionSummary: { + type: "ssh", + username: "token", + hostRedacted: true, + portRedacted: true, + instructions: "ssh token@203.0.113.10", + }, + connectionSecretRef: null, + metadata: { + safeLabel: "setup", + connectUrl: "https://203.0.113.10/setup", + }, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as typeof req & { actor: Record }).actor = actor; + next(); + }); + app.use("/api", environmentRoutes({} as never)); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status((err as { status?: number }).status ?? 500).json({ + error: err instanceof Error ? err.message : String(err), + }); + }); + return app; +} + +function boardActor(overrides: Record = {}) { + return { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: true, + ...overrides, + }; +} + +function agentActor() { + return { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }; +} + +function loggedActivityJson() { + return JSON.stringify(mockLogActivity.mock.calls); +} + +describe("environment customImage setup routes", () => { + beforeEach(() => { + mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockInstanceSettingsService.listCompanyIds.mockReset(); + Object.values(mockEnvironmentService).forEach((mock) => mock.mockReset()); + Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset()); + mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); + Object.values(mockSecretService).forEach((mock) => mock.mockReset()); + mockLogActivity.mockReset(); + + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + mockEnvironmentCustomImageService.getOverview.mockResolvedValue({ + activeTemplate: null, + activeSession: null, + latestSession: null, + }); + mockEnvironmentCustomImageService.getActiveTemplate.mockResolvedValue(null); + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(null); + mockEnvironmentCustomImageService.startSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { + type: "ssh", + command: "ssh token@203.0.113.10 -p 2222", + }, + }); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { + type: "ssh", + command: "ssh token@203.0.113.10 -p 2222", + }, + }); + mockEnvironmentCustomImageService.finishSetupSession.mockResolvedValue({ + session: createSession({ + status: "promoted", + promotedTemplateId: "template-2", + finishedAt: now, + }), + template: createTemplate({ + id: "template-2", + templateRef: "captured-template-secret", + sourceTemplateRef: "snapshot-secret-ref", + metadata: { sandboxId: "sandbox-secret" }, + }), + connectionPayload: null, + }); + mockEnvironmentCustomImageService.cancelSetupSession.mockResolvedValue(createSession({ + status: "cancelled", + finishedAt: now, + failureReason: "operator requested", + })); + mockEnvironmentCustomImageService.rollbackTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-1", templateRef: "old-template-secret" }), + supersededTemplate: createTemplate({ id: "template-2", templateRef: "new-template-secret" }), + }); + mockEnvironmentCustomImageService.disableTemplate.mockResolvedValue(createTemplate({ + id: "template-2", + templateRef: "disabled-template-secret", + status: "revoked", + })); + }); + + it("starts a setup session, returns the live payload, and logs redacted details", async () => { + const res = await request(createApp(boardActor())) + .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") + .send({ ttlSeconds: 3600 }); + + expect(res.status).toBe(201); + expect(res.body.connectionPayload.command).toContain("203.0.113.10"); + expect(mockEnvironmentCustomImageService.startSetupSession).toHaveBeenCalledWith({ + companyId: "company-1", + environmentId: "env-1", + templateId: null, + ttlSeconds: 3600, + actor: { + userId: "user-1", + agentId: null, + }, + }); + const activity = loggedActivityJson(); + expect(activity).not.toContain("203.0.113.10"); + expect(activity).not.toContain("lease-secret"); + expect(activity).not.toContain("snapshot-secret-ref"); + }); + + it("refreshes active setup status and can return the live payload to board admins", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + + const res = await request(createApp(boardActor())) + .get("/api/environment-custom-image-setup-sessions/session-1"); + + expect(res.status).toBe(200); + expect(res.body.connectionPayload.command).toContain("203.0.113.10"); + expect(mockEnvironmentCustomImageService.refreshSetupSession).toHaveBeenCalledWith({ + sessionId: "session-1", + includeConnectionPayload: true, + }); + }); + + it("denies agent API key actors before customImage state or payloads are read", async () => { + const app = createApp(agentActor()); + const start = await request(app) + .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") + .send({}); + const status = await request(app) + .get("/api/environment-custom-image-setup-sessions/session-1"); + + expect(start.status).toBe(403); + expect(status.status).toBe(403); + expect(mockEnvironmentCustomImageService.startSetupSession).not.toHaveBeenCalled(); + expect(mockEnvironmentCustomImageService.getSessionById).not.toHaveBeenCalled(); + expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); + }); + + it("denies non-admin board users before connection payload refresh", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + + const res = await request(createApp(boardActor({ + companyIds: ["company-2"], + isInstanceAdmin: false, + }))) + .get("/api/environment-custom-image-setup-sessions/session-1"); + + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.getSessionById).not.toHaveBeenCalled(); + expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); + }); + + it("denies single-company fallback when the board actor is not a member", async () => { + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-2"]); + + const res = await request(createApp(boardActor({ + companyIds: ["company-1"], + isInstanceAdmin: false, + }))) + .post("/api/environments/env-1/custom-image-setup-sessions") + .send({}); + + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.startSetupSession).not.toHaveBeenCalled(); + }); + + it("finishes and promotes a template while logging redacted template details", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + + const res = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/finish") + .send({ metadata: { safeLabel: "done" } }); + + expect(res.status).toBe(200); + expect(res.body.template.templateRef).toBe("captured-template-secret"); + expect(mockEnvironmentCustomImageService.finishSetupSession).toHaveBeenCalledWith({ + sessionId: "session-1", + metadata: { safeLabel: "done" }, + }); + const activity = loggedActivityJson(); + expect(activity).not.toContain("captured-template-secret"); + expect(activity).not.toContain("snapshot-secret-ref"); + expect(activity).not.toContain("sandbox-secret"); + }); + + it("cancels active sessions without logging lease details", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + + const res = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/cancel") + .send({ reason: "operator requested" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("cancelled"); + expect(mockEnvironmentCustomImageService.cancelSetupSession).toHaveBeenCalledWith({ + sessionId: "session-1", + reason: "operator requested", + }); + expect(loggedActivityJson()).not.toContain("lease-secret"); + }); + + it("rolls back and disables active templates through company-scoped routes", async () => { + const app = createApp(boardActor()); + const rollback = await request(app) + .post("/api/environments/env-1/custom-image-template/rollback?companyId=company-1") + .send({}); + const disable = await request(app) + .delete("/api/environments/env-1/custom-image-template?companyId=company-1&deleteProviderTemplate=true"); + + expect(rollback.status).toBe(200); + expect(disable.status).toBe(200); + expect(mockEnvironmentCustomImageService.rollbackTemplate).toHaveBeenCalledWith({ + companyId: "company-1", + environmentId: "env-1", + }); + expect(mockEnvironmentCustomImageService.disableTemplate).toHaveBeenCalledWith({ + companyId: "company-1", + environmentId: "env-1", + deleteProviderTemplate: true, + }); + const activity = loggedActivityJson(); + expect(activity).not.toContain("new-template-secret"); + expect(activity).not.toContain("old-template-secret"); + expect(activity).not.toContain("disabled-template-secret"); + }); +}); diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts new file mode 100644 index 0000000000..c3cf2c10a0 --- /dev/null +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -0,0 +1,477 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + createDb, + environmentCustomImageSetupSessions, + environmentCustomImageTemplates, + environments, + plugins, +} from "@paperclipai/db"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import { + environmentCustomImageService, +} from "../services/environment-custom-images.js"; +import { + fingerprintEnvironmentSandboxProviderConfig, +} from "../services/environment-custom-image-runtime.js"; +import { + resolveEnvironmentDriverConfigForRuntime, +} from "../services/environment-config.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres environment customImage tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function pluginManifest() { + return { + id: "paperclip.fake-sandbox-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Fake Sandbox Provider", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Sandbox Provider", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { + field: "customTemplate", + unsetFields: ["image"], + }, + supportsTemplateDelete: true, + configSchema: { type: "object" }, + }, + ], + } as const; +} + +function createWorkerManager() { + const call = vi.fn(async (_pluginId: string, method: string, params: Record) => { + if (method === "environmentStartInteractiveSetup") { + return { + providerLeaseId: `lease-${params.sessionId}`, + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }, + connectionPayload: { + type: "ssh", + command: "ssh sandbox@203.0.113.10", + expiresAt: params.expiresAt, + }, + expiresAt: params.expiresAt, + metadata: { + connectUrl: "https://203.0.113.10/setup", + safeLabel: "setup", + }, + }; + } + if (method === "environmentGetInteractiveSetup") { + return { + providerLeaseId: params.providerLeaseId, + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }, + connectionPayload: { + type: "ssh", + command: "ssh sandbox@203.0.113.10", + }, + metadata: { + safeLabel: "setup", + }, + }; + } + if (method === "environmentCaptureTemplate") { + return { + templateRef: `snapshot-${String(params.providerLeaseId).slice(-8)}`, + templateKind: "snapshot", + metadata: { + provider: "fake-plugin", + sourceTemplateRefRedacted: Boolean(params.sourceTemplateRef), + previousTemplateRefRedacted: Boolean(params.previousTemplateRef), + }, + }; + } + if (method === "environmentCancelInteractiveSetup") { + return { + status: params.reason === "timed_out" ? "timed_out" : "cancelled", + metadata: { provider: "fake-plugin", found: true }, + }; + } + if (method === "environmentDeleteTemplate") { + return { deleted: true, metadata: { provider: "fake-plugin" } }; + } + throw new Error(`Unexpected plugin call: ${method}`); + }); + return { + call, + isRunning: vi.fn(() => true), + } as unknown as PluginWorkerManager & { call: typeof call }; +} + +describeEmbeddedPostgres("environmentCustomImageService", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("environment-custom-images"); + stopDb = started.stop; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(environmentCustomImageSetupSessions); + await db.delete(environmentCustomImageTemplates); + await db.delete(plugins); + await db.delete(environments); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + }); + + async function seed() { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const environmentId = randomUUID(); + await db.insert(companies).values([ + { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` }, + { id: otherCompanyId, name: "Other", issuePrefix: `B${otherCompanyId.slice(0, 4)}` }, + ]); + await db.insert(environments).values({ + id: environmentId, + name: `Fake ${environmentId.slice(0, 8)}`, + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:base", + reuseLease: false, + }, + envVars: {}, + }); + await db.insert(plugins).values({ + pluginKey: "paperclip.fake-sandbox-provider", + packageName: "paperclip-plugin-fake-sandbox", + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: pluginManifest(), + status: "ready", + }); + return { companyId, otherCompanyId, environmentId }; + } + + it("starts, refreshes, finishes, refreshes again, and rolls back setup sessions", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + ttlSeconds: 600, + }); + + expect(started.session.status).toBe("waiting_for_user"); + expect(started.connectionPayload?.command).toContain("203.0.113.10"); + expect(JSON.stringify(started.session.metadata)).not.toContain("203.0.113.10"); + + const status = await service.refreshSetupSession({ + sessionId: started.session.id, + includeConnectionPayload: true, + }); + expect(status.connectionPayload?.command).toContain("ssh sandbox@203.0.113.10"); + + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + expect(promoted.session.status).toBe("promoted"); + expect(promoted.template.status).toBe("active"); + expect(promoted.template.templateKind).toBe("snapshot"); + expect(promoted.template.metadata).toMatchObject({ + runtimeConfigBinding: { + field: "customTemplate", + unsetFields: ["image"], + }, + }); + + const refresh = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + }); + const replacement = await service.finishSetupSession({ sessionId: refresh.session.id }); + expect(replacement.template.id).not.toBe(promoted.template.id); + + const rollback = await service.rollbackTemplate({ companyId, environmentId }); + expect(rollback.activeTemplate.id).toBe(promoted.template.id); + expect(rollback.supersededTemplate.id).toBe(replacement.template.id); + }); + + it("revokes the active template before deleting the provider template", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + let statusAtProviderDelete: string | null = null; + workerManager.call.mockImplementation(async (_pluginId, method) => { + if (method !== "environmentDeleteTemplate") { + throw new Error(`Unexpected plugin call after setup: ${method}`); + } + const [row] = await db + .select({ status: environmentCustomImageTemplates.status }) + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.id, promoted.template.id)); + statusAtProviderDelete = row?.status ?? null; + return { deleted: true, metadata: { provider: "fake-plugin" } }; + }); + + const disabled = await service.disableTemplate({ + companyId, + environmentId, + deleteProviderTemplate: true, + }); + + expect(disabled.status).toBe("revoked"); + expect(statusAtProviderDelete).toBe("revoked"); + expect(workerManager.call).toHaveBeenLastCalledWith( + expect.any(String), + "environmentDeleteTemplate", + expect.objectContaining({ + templateRef: promoted.template.templateRef, + reason: "disabled", + }), + undefined, + ); + }); + + it("cancels and times out setup sessions without changing the active template", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + }); + const cancelled = await service.cancelSetupSession({ + sessionId: started.session.id, + reason: "user_cancelled", + }); + expect(cancelled.status).toBe("cancelled"); + expect(await service.getActiveTemplate({ companyId, environmentId })).toBeNull(); + + const expired = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + ttlSeconds: 60, + now: new Date("2026-06-25T00:00:00.000Z"), + }); + const cleanup = await service.cleanupExpiredSetupSessions({ + now: new Date("2026-06-25T00:02:00.000Z"), + }); + expect(cleanup).toMatchObject({ scanned: 1, timedOut: 1, failed: 0 }); + const timedOut = await service.getSessionById(expired.session.id); + expect(timedOut?.status).toBe("timed_out"); + }); + + it("rejects templates from another company or environment", async () => { + const { companyId, otherCompanyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + const [otherTemplate] = await db.insert(environmentCustomImageTemplates).values({ + companyId: otherCompanyId, + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-other-company", + status: "active", + }).returning(); + + await expect(service.startSetupSession({ + companyId, + environmentId, + templateId: otherTemplate!.id, + actor: { userId: "user-1" }, + })).rejects.toThrow("Setup template must be the active template"); + }); + + it("applies the active template regardless of base-config changes and falls back when none exists", async () => { + const { companyId, environmentId } = await seed(); + const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + + const fallback = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }, { heartbeatRunId: randomUUID() }); + expect(fallback.driver).toBe("sandbox"); + expect(fallback.config).toMatchObject({ image: "fake:base" }); + + await db.insert(environmentCustomImageTemplates).values({ + companyId, + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any), + status: "active", + }); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }, { heartbeatRunId: randomUUID() }); + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(resolved.config).not.toHaveProperty("image"); + + // A stored fingerprint that no longer matches the current base config (e.g. the + // config dialog re-saved the environment, or the user tweaked resource/lease + // knobs) must NOT silently discard the captured template. + await db.update(environmentCustomImageTemplates) + .set({ sourceEnvironmentConfigFingerprint: "stale" }) + .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); + const afterConfigChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }, { heartbeatRunId: randomUUID() }); + expect(afterConfigChange.driver).toBe("sandbox"); + expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(afterConfigChange.config).not.toHaveProperty("image"); + }); + + it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => { + const { companyId, environmentId } = await seed(); + const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + + await db.insert(environmentCustomImageTemplates).values({ + companyId, + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-active", + status: "active", + }); + + // No issueId/heartbeatRunId and no opt-in: an operator Test probe would + // otherwise silently boot the base image instead of the captured template. + const withoutOptIn = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }); + expect(withoutOptIn.config).toMatchObject({ image: "fake:base" }); + expect(withoutOptIn.config).not.toHaveProperty("snapshot"); + + // The Test route opts in explicitly so the probe uses the captured image. + const withOptIn = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }, { applyCustomImageTemplate: true }); + expect(withOptIn.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(withOptIn.config).not.toHaveProperty("image"); + }); + + it("applies provider-declared runtime config bindings for captured templates", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + companyId, + environmentId, + actor: { userId: "user-1" }, + }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environment.id, + driver: "sandbox", + config: environment.config, + }, { heartbeatRunId: randomUUID() }); + + expect(promoted.template.metadata).toMatchObject({ + runtimeConfigBinding: { + field: "customTemplate", + unsetFields: ["image"], + }, + }); + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef }); + expect(resolved.config).not.toHaveProperty("image"); + expect(resolved.config).not.toHaveProperty("snapshot"); + }); +}); + +describe("fingerprintEnvironmentSandboxProviderConfig", () => { + it("ignores excluded secret-ref paths so secretizing a credential on save keeps the fingerprint stable", () => { + // Mirrors the real flow: a template is captured before "Save Environment" + // rewrites the raw apiKey into a secret ref. Excluding the secret path must + // keep the capture-time and run-time fingerprints equal. + const rawCredential = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + apiKey: "raw-api-key-value", + } as any; + const secretRefCredential = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + apiKey: "0d9a7b0e-a3ba-4605-8a68-eb230d494e98", + } as any; + + const exclude = { excludePaths: ["apiKey"] }; + expect(fingerprintEnvironmentSandboxProviderConfig(rawCredential, exclude)).toBe( + fingerprintEnvironmentSandboxProviderConfig(secretRefCredential, exclude), + ); + + // Without exclusion the credential change would (incorrectly) invalidate the template. + expect(fingerprintEnvironmentSandboxProviderConfig(rawCredential)).not.toBe( + fingerprintEnvironmentSandboxProviderConfig(secretRefCredential), + ); + + // A meaningful base change (e.g. switching the base image) still changes the fingerprint. + expect(fingerprintEnvironmentSandboxProviderConfig(rawCredential, exclude)).not.toBe( + fingerprintEnvironmentSandboxProviderConfig( + { ...rawCredential, image: "daytonaio/sandbox:0.9.0" } as any, + exclude, + ), + ); + }); +}); diff --git a/server/src/__tests__/environment-instance-routes.test.ts b/server/src/__tests__/environment-instance-routes.test.ts index e146f91fb5..a0729c8a84 100644 --- a/server/src/__tests__/environment-instance-routes.test.ts +++ b/server/src/__tests__/environment-instance-routes.test.ts @@ -22,6 +22,19 @@ const mockEnvironmentService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockEnvironmentCustomImageService = vi.hoisted(() => ({ + getOverview: vi.fn(), + getActiveTemplate: vi.fn(), + getSessionById: vi.fn(), + startSetupSession: vi.fn(), + refreshSetupSession: vi.fn(), + finishSetupSession: vi.fn(), + cancelSetupSession: vi.fn(), + rollbackTemplate: vi.fn(), + disableTemplate: vi.fn(), + cleanupExpiredSetupSessions: vi.fn(), +})); + const mockExecutionWorkspaceService = vi.hoisted(() => ({ clearEnvironmentSelection: vi.fn(), })); @@ -40,6 +53,7 @@ const mockSecretService = vi.hoisted(() => ({ vi.mock("../services/index.js", () => ({ issueService: () => mockIssueService, instanceSettingsService: () => mockInstanceSettingsService, + environmentCustomImageService: () => mockEnvironmentCustomImageService, logActivity: mockLogActivity, projectService: () => mockProjectService, })); @@ -58,6 +72,24 @@ vi.mock("../services/secrets.js", () => ({ vi.mock("../services/plugin-environment-driver.js", () => ({ listReadyPluginEnvironmentDrivers: vi.fn(async () => []), + resolvePluginSandboxProviderDriverByKey: vi.fn(async () => null), + validatePluginEnvironmentDriverConfig: vi.fn(async ({ config }) => config), + validatePluginSandboxProviderConfig: vi.fn(async ({ provider, config }) => ({ + normalizedConfig: config, + pluginId: `plugin-${provider}`, + pluginKey: `plugin.${provider}`, + driver: { + driverKey: provider, + kind: "sandbox_provider", + displayName: provider, + configSchema: { type: "object" }, + }, + })), + startPluginEnvironmentInteractiveSetup: vi.fn(), + getPluginEnvironmentInteractiveSetup: vi.fn(), + capturePluginEnvironmentTemplate: vi.fn(), + cancelPluginEnvironmentInteractiveSetup: vi.fn(), + deletePluginEnvironmentTemplate: vi.fn(), })); function createEnvironment(overrides: Record = {}) { @@ -97,6 +129,14 @@ describe("environment instance routes", () => { mockEnvironmentService.list.mockReset(); mockEnvironmentService.getById.mockReset(); mockEnvironmentService.create.mockReset(); + Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset()); + mockEnvironmentCustomImageService.getOverview.mockResolvedValue({ + activeTemplate: null, + activeSession: null, + latestSession: null, + }); + mockEnvironmentCustomImageService.getActiveTemplate.mockResolvedValue(null); + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(null); mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); mockLogActivity.mockReset(); mockSecretService.create.mockReset(); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 208e3b5b0f..a45b298012 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -36,6 +36,19 @@ const mockEnvironmentService = vi.hoisted(() => ({ getLeaseById: vi.fn(), })); +const mockEnvironmentCustomImageService = vi.hoisted(() => ({ + getOverview: vi.fn(), + getActiveTemplate: vi.fn(), + getSessionById: vi.fn(), + startSetupSession: vi.fn(), + refreshSetupSession: vi.fn(), + finishSetupSession: vi.fn(), + cancelSetupSession: vi.fn(), + rollbackTemplate: vi.fn(), + disableTemplate: vi.fn(), + cleanupExpiredSetupSessions: vi.fn(), +})); + const mockLogActivity = vi.hoisted(() => vi.fn()); const mockProbeEnvironment = vi.hoisted(() => vi.fn()); const mockSecretService = vi.hoisted(() => ({ @@ -52,11 +65,17 @@ const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn()); const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn()); const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn()); const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); +const mockStartPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn()); +const mockGetPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn()); +const mockCapturePluginEnvironmentTemplate = vi.hoisted(() => vi.fn()); +const mockCancelPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn()); +const mockDeletePluginEnvironmentTemplate = vi.hoisted(() => vi.fn()); const mockExecutionWorkspaceService = vi.hoisted(() => ({})); vi.mock("../services/index.js", () => ({ issueService: () => mockIssueService, instanceSettingsService: () => mockInstanceSettingsService, + environmentCustomImageService: () => mockEnvironmentCustomImageService, environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, @@ -81,6 +100,11 @@ vi.mock("../services/execution-workspaces.js", () => ({ vi.mock("../services/plugin-environment-driver.js", () => ({ listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers, resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, + startPluginEnvironmentInteractiveSetup: mockStartPluginEnvironmentInteractiveSetup, + getPluginEnvironmentInteractiveSetup: mockGetPluginEnvironmentInteractiveSetup, + capturePluginEnvironmentTemplate: mockCapturePluginEnvironmentTemplate, + cancelPluginEnvironmentInteractiveSetup: mockCancelPluginEnvironmentInteractiveSetup, + deletePluginEnvironmentTemplate: mockDeletePluginEnvironmentTemplate, validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig, validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig, })); @@ -163,6 +187,14 @@ describe("environment routes", () => { mockEnvironmentService.update.mockReset(); mockEnvironmentService.listLeases.mockReset(); mockEnvironmentService.getLeaseById.mockReset(); + Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset()); + mockEnvironmentCustomImageService.getOverview.mockResolvedValue({ + activeTemplate: null, + activeSession: null, + latestSession: null, + }); + mockEnvironmentCustomImageService.getActiveTemplate.mockResolvedValue(null); + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(null); mockLogActivity.mockReset(); mockProbeEnvironment.mockReset(); mockSecretService.create.mockReset(); @@ -223,6 +255,11 @@ describe("environment routes", () => { )); mockListReadyPluginEnvironmentDrivers.mockReset(); mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]); + mockStartPluginEnvironmentInteractiveSetup.mockReset(); + mockGetPluginEnvironmentInteractiveSetup.mockReset(); + mockCapturePluginEnvironmentTemplate.mockReset(); + mockCancelPluginEnvironmentInteractiveSetup.mockReset(); + mockDeletePluginEnvironmentTemplate.mockReset(); mockAccessService.decide.mockResolvedValue({ allowed: true, explanation: "Allowed by test harness", @@ -315,6 +352,15 @@ describe("environment routes", () => { driverKey: "secure-plugin", displayName: "Secure Sandbox", description: "Provisions schema-driven cloud sandboxes.", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { + field: "template", + unsetFields: ["image"], + }, + supportsTemplateDelete: true, configSchema: { type: "object", properties: { @@ -337,6 +383,15 @@ describe("environment routes", () => { status: "supported", supportsRunExecution: true, supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { + field: "template", + unsetFields: ["image"], + }, + supportsTemplateDelete: true, displayName: "Secure Sandbox", source: "plugin", pluginKey: "acme.secure-sandbox-provider", diff --git a/server/src/__tests__/issue-watchdogs-routes.test.ts b/server/src/__tests__/issue-watchdogs-routes.test.ts index 15bc1a4d83..c3139b147b 100644 --- a/server/src/__tests__/issue-watchdogs-routes.test.ts +++ b/server/src/__tests__/issue-watchdogs-routes.test.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, + agentRuntimeState, agentWakeupRequests, agents, companies, @@ -51,6 +52,7 @@ describeEmbeddedPostgres("issue watchdog routes", () => { await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); + await db.delete(agentRuntimeState); await db.delete(issueRelations); await db.delete(issueWatchdogs); await db.delete(issues); diff --git a/server/src/__tests__/plugin-environment-driver-seam.test.ts b/server/src/__tests__/plugin-environment-driver-seam.test.ts index 013994099c..56c13d091e 100644 --- a/server/src/__tests__/plugin-environment-driver-seam.test.ts +++ b/server/src/__tests__/plugin-environment-driver-seam.test.ts @@ -27,6 +27,11 @@ const baseManifest: PaperclipPluginManifestV1 = { { driverKey: "fake-plugin", displayName: "Fake plugin", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + supportsTemplateDelete: true, configSchema: { type: "object", properties: { @@ -69,6 +74,9 @@ describe("plugin environment driver seam", () => { expect(validator.getRequiredCapabilities("environment.acquireLease")).toEqual([ "environment.drivers.register", ]); + expect(validator.getRequiredCapabilities("environment.captureTemplate")).toEqual([ + "environment.drivers.register", + ]); expect(validator.checkOperation(baseManifest, "environment.execute").allowed).toBe(true); const withoutCapability = { @@ -96,6 +104,20 @@ describe("plugin environment driver seam", () => { metadata: { environmentId: params.environmentId }, }; }, + async onEnvironmentStartInteractiveSetup(params) { + return { + providerLeaseId: `setup-${params.sessionId}`, + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }, + connectionPayload: null, + metadata: { environmentId: params.environmentId }, + }; + }, }); const stdin = new PassThrough(); @@ -121,6 +143,7 @@ describe("plugin environment driver seam", () => { expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true); if (!isJsonRpcSuccessResponse(initializeResponse)) return; expect(initializeResponse.result.supportedMethods).toContain("environmentProbe"); + expect(initializeResponse.result.supportedMethods).toContain("environmentStartInteractiveSetup"); stdin.write(serializeMessage(createRequest("environmentProbe", { driverKey: "fake-plugin", @@ -139,6 +162,28 @@ describe("plugin environment driver seam", () => { metadata: { environmentId: "environment-1" }, }); + stdin.write(serializeMessage(createRequest("environmentStartInteractiveSetup", { + driverKey: "fake-plugin", + companyId: "company-1", + environmentId: "environment-1", + sessionId: "session-1", + config: { template: "base" }, + }, 3))); + await waitForResponses(responses, 3); + + const setupResponse = responses[2]; + expect(isJsonRpcSuccessResponse(setupResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(setupResponse)) return; + expect(setupResponse.result).toMatchObject({ + providerLeaseId: "setup-session-1", + status: "waiting_for_user", + connectionSummary: { + type: "ssh", + hostRedacted: true, + portRedacted: true, + }, + }); + stdin.write(serializeMessage(createRequest("environmentExecute", { driverKey: "fake-plugin", companyId: "company-1", @@ -146,10 +191,10 @@ describe("plugin environment driver seam", () => { config: { template: "base" }, lease: { providerLeaseId: "lease-1" }, command: "echo", - }, 3))); - await waitForResponses(responses, 3); + }, 4))); + await waitForResponses(responses, 4); - const executeResponse = responses[2]; + const executeResponse = responses[3]; expect(isJsonRpcErrorResponse(executeResponse)).toBe(true); if (!isJsonRpcErrorResponse(executeResponse)) return; expect(executeResponse.error.code).toBe(PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index b2972bc542..e9111520c3 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -2199,6 +2199,7 @@ describe("realizeExecutionWorkspace", () => { const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-")); await runGit(bareRemote, ["init", "--bare"]); await runGit(repoRoot, ["remote", "add", "origin", bareRemote]); + await runGit(repoRoot, ["branch", "-f", "master"]); await runGit(repoRoot, ["push", "-u", "origin", "main", "master"]); await runGit(repoRoot, ["fetch", "origin"]); // Explicitly set refs/remotes/origin/HEAD to exercise the symbolic-ref path diff --git a/server/src/index.ts b/server/src/index.ts index 331488e985..93e867f895 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -38,6 +38,7 @@ import { feedbackService, backfillPrincipalAccessCompatibility, bootstrapExecutionPolicyFromEnv, + environmentCustomImageService, heartbeatService, instanceSettingsService, reconcileCloudUpstreamRunsOnStartup, @@ -775,6 +776,7 @@ export async function startServer(): Promise { if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any, { pluginWorkerManager }); + const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager }); const routines = routineService(db as any, { pluginWorkerManager }); // Reap orphaned runs before timer ticks start so wakeups cannot coalesce @@ -847,6 +849,11 @@ export async function startServer(): Promise { if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) { logger.warn({ ...reviewed }, "startup productivity reconciliation created or updated review work"); } + + const setupCleanup = await environmentCustomImages.cleanupExpiredSetupSessions(); + if (setupCleanup.timedOut > 0 || setupCleanup.failed > 0) { + logger.warn({ ...setupCleanup }, "startup environment customImage setup cleanup changed sessions"); + } })().catch((err) => { logger.error({ err }, "startup heartbeat recovery failed"); }); @@ -881,6 +888,17 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "routine scheduler tick failed"); }); + + void environmentCustomImages + .cleanupExpiredSetupSessions() + .then((result) => { + if (result.timedOut > 0 || result.failed > 0) { + logger.warn({ ...result }, "environment customImage setup cleanup changed sessions"); + } + }) + .catch((err) => { + logger.error({ err }, "environment customImage setup cleanup failed"); + }); // Periodically reap orphaned runs (5-min staleness threshold) and make sure // persisted queued work is still being driven forward. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 57c63ea9ad..e86851d871 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -362,6 +362,11 @@ export function agentRoutes( issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, + // Apply the active custom-image template so the Test boots with the + // operator's captured sandbox customizations and prepared image state, + // matching what real agent runs use. Without this the test would + // silently fall back to the base image. + applyCustomImageTemplate: true, }); } catch (err) { return { diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 80e6d49176..b1f1887910 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -2,14 +2,20 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { AGENT_ADAPTER_TYPES, + cancelEnvironmentCustomImageSetupSessionSchema, createEnvironmentSchema, + finishEnvironmentCustomImageSetupSessionSchema, getEnvironmentCapabilities, probeEnvironmentConfigSchema, + redactEnvironmentCustomImageSetupSession, + redactEnvironmentCustomImageTemplate, + startEnvironmentCustomImageSetupSessionSchema, updateEnvironmentSchema, } from "@paperclipai/shared"; import { conflict, forbidden, unprocessable } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { + environmentCustomImageService, issueService, instanceSettingsService, logActivity, @@ -37,6 +43,9 @@ export function environmentRoutes( ) { const router = Router(); const svc = environmentService(db); + const customImages = environmentCustomImageService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); const instanceSettings = instanceSettingsService(db); @@ -62,6 +71,17 @@ export function environmentRoutes( assertBoardOrgAccess(req); } + function assertCustomImageCompanyAccess(req: Request, companyId: string) { + if (req.actor.type !== "board") { + throw forbidden("Board access required"); + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowedCompanies = req.actor.companyIds ?? []; + if (!allowedCompanies.includes(companyId)) { + throw forbidden("User does not have access to this company"); + } + } + function canReadFullInstanceEnvironment(req: Request) { return req.actor.type === "board" && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin); @@ -119,6 +139,47 @@ export function environmentRoutes( ); } + async function logEnvironmentCustomImageActivity(input: { + actor: ReturnType; + companyId: string; + action: string; + entityId: string; + details: Record; + }) { + await logActivity(db, { + companyId: input.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: input.action, + entityType: "environment", + entityId: input.entityId, + details: input.details, + }); + } + + async function resolveCustomImageCompanyId(req: Request): Promise { + const queryCompanyId = + typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0 + ? req.query.companyId.trim() + : null; + if (queryCompanyId) { + assertCustomImageCompanyAccess(req, queryCompanyId); + return queryCompanyId; + } + if (req.actor.type === "board" && req.actor.companyIds?.length === 1) { + return req.actor.companyIds[0]!; + } + const companyIds = await instanceSettings.listCompanyIds(); + if (companyIds.length === 1 && companyIds[0]) { + const companyId = companyIds[0]; + assertCustomImageCompanyAccess(req, companyId); + return companyId; + } + throw unprocessable("companyId query parameter is required for environment customImage setup."); + } + async function resolveEnvironmentSecretContextCompanyId( req: Request, environmentId: string, @@ -186,6 +247,52 @@ export function environmentRoutes( return details; } + function setupSessionActivityDetails(session: { + id: string; + environmentId: string; + provider: string; + status: string; + providerLeaseId: string | null; + baseTemplateRef: string | null; + connectionSummary?: Record | null; + connectionSecretRef: string | null; + metadata?: Record | null; + }) { + return redactEnvironmentCustomImageSetupSession({ + sessionId: session.id, + environmentId: session.environmentId, + provider: session.provider, + status: session.status, + providerLeaseId: session.providerLeaseId, + baseTemplateRef: session.baseTemplateRef, + connectionSummary: session.connectionSummary, + connectionSecretRef: session.connectionSecretRef, + metadata: session.metadata, + }); + } + + function templateActivityDetails(template: { + id: string; + environmentId: string; + provider: string; + status: string; + templateKind: string; + templateRef: string | null; + sourceTemplateRef: string | null; + metadata?: Record | null; + }) { + return redactEnvironmentCustomImageTemplate({ + templateId: template.id, + environmentId: template.environmentId, + provider: template.provider, + status: template.status, + templateKind: template.templateKind, + templateRef: template.templateRef, + sourceTemplateRef: template.sourceTemplateRef, + metadata: template.metadata, + }); + } + router.get("/companies/:companyId/environments", async (req, res) => { assertCanReadInstanceEnvironments(req); const rows = await svc.list({ @@ -211,7 +318,13 @@ export function environmentRoutes( supportsSavedProbe: true, supportsUnsavedProbe: true, supportsRunExecution: true, - supportsReusableLeases: true, + supportsReusableLeases: driver.supportsReusableLeases ?? true, + supportsInteractiveSetup: driver.supportsInteractiveSetup, + interactiveSetupConnectionTypes: driver.interactiveSetupConnectionTypes, + supportsTemplateCapture: driver.supportsTemplateCapture, + templateRefKind: driver.templateRefKind, + templateConfigBinding: driver.templateConfigBinding, + supportsTemplateDelete: driver.supportsTemplateDelete, displayName: driver.displayName, description: driver.description, source: "plugin" as const, @@ -224,6 +337,156 @@ export function environmentRoutes( )); }); + router.get("/environments/:environmentId/custom-image-template", async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); + const overview = await customImages.getOverview({ + companyId, + environmentId: req.params.environmentId as string, + }); + res.json(overview); + }); + + router.post( + "/environments/:environmentId/custom-image-setup-sessions", + validate(startEnvironmentCustomImageSetupSessionSchema), + async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); + const actor = getActorInfo(req); + const result = await customImages.startSetupSession({ + companyId, + environmentId: req.params.environmentId as string, + templateId: req.body.templateId ?? null, + ttlSeconds: req.body.ttlSeconds ?? null, + actor: { + userId: actor.actorType === "user" ? actor.actorId : null, + agentId: actor.agentId, + }, + }); + await logEnvironmentCustomImageActivity({ + actor, + companyId, + action: "environment.custom_image_setup.started", + entityId: result.session.environmentId, + details: setupSessionActivityDetails(result.session), + }); + res.status(201).json(result); + }, + ); + + router.get("/environment-custom-image-setup-sessions/:sessionId", async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const session = await customImages.getSessionById(req.params.sessionId as string); + if (!session) { + res.status(404).json({ error: "Environment customImage setup session not found" }); + return; + } + assertCustomImageCompanyAccess(req, session.companyId); + const result = await customImages.refreshSetupSession({ + sessionId: session.id, + includeConnectionPayload: true, + }); + res.json(result); + }); + + router.post( + "/environment-custom-image-setup-sessions/:sessionId/finish", + validate(finishEnvironmentCustomImageSetupSessionSchema), + async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const session = await customImages.getSessionById(req.params.sessionId as string); + if (!session) { + res.status(404).json({ error: "Environment customImage setup session not found" }); + return; + } + assertCustomImageCompanyAccess(req, session.companyId); + const actor = getActorInfo(req); + const result = await customImages.finishSetupSession({ + sessionId: session.id, + metadata: req.body.metadata, + }); + await logEnvironmentCustomImageActivity({ + actor, + companyId: session.companyId, + action: "environment.custom_image_setup.finished", + entityId: result.session.environmentId, + details: { + session: setupSessionActivityDetails(result.session), + template: templateActivityDetails(result.template), + }, + }); + res.json(result); + }, + ); + + router.post( + "/environment-custom-image-setup-sessions/:sessionId/cancel", + validate(cancelEnvironmentCustomImageSetupSessionSchema), + async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const session = await customImages.getSessionById(req.params.sessionId as string); + if (!session) { + res.status(404).json({ error: "Environment customImage setup session not found" }); + return; + } + assertCustomImageCompanyAccess(req, session.companyId); + const actor = getActorInfo(req); + const cancelled = await customImages.cancelSetupSession({ + sessionId: session.id, + reason: req.body.reason ?? null, + }); + await logEnvironmentCustomImageActivity({ + actor, + companyId: session.companyId, + action: "environment.custom_image_setup.cancelled", + entityId: cancelled.environmentId, + details: setupSessionActivityDetails(cancelled), + }); + res.json(cancelled); + }, + ); + + router.post("/environments/:environmentId/custom-image-template/rollback", async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); + const actor = getActorInfo(req); + const result = await customImages.rollbackTemplate({ + companyId, + environmentId: req.params.environmentId as string, + }); + await logEnvironmentCustomImageActivity({ + actor, + companyId, + action: "environment.custom_image_template.rolled_back", + entityId: req.params.environmentId as string, + details: { + activeTemplate: templateActivityDetails(result.activeTemplate), + supersededTemplate: templateActivityDetails(result.supersededTemplate), + }, + }); + res.json(result); + }); + + router.delete("/environments/:environmentId/custom-image-template", async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); + const actor = getActorInfo(req); + const template = await customImages.disableTemplate({ + companyId, + environmentId: req.params.environmentId as string, + deleteProviderTemplate: req.query.deleteProviderTemplate === "true", + }); + await logEnvironmentCustomImageActivity({ + actor, + companyId, + action: "environment.custom_image_template.disabled", + entityId: req.params.environmentId as string, + details: templateActivityDetails(template), + }); + res.json(template); + }); + router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCanAccessInstanceEnvironments(req); @@ -473,8 +736,6 @@ export function environmentRoutes( const companyId = req.params.companyId as string; assertCanAccessInstanceEnvironments(req); if (req.body.driver === "sandbox") { - // Draft sandbox probes can resolve unbound secret refs, so require - // the same company-scoped secret-read capability before normalization. await assertCanReadSecretsForDraftProbe(req, companyId); } const actor = getActorInfo(req); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 35b7f73af7..81b9858619 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -72,8 +72,13 @@ import { workspaceRuntimeControlTargetSchema, // Environments createEnvironmentSchema, + cancelEnvironmentCustomImageSetupSessionSchema, + environmentCustomImageSetupSessionSchema, + environmentCustomImageTemplateSchema, + finishEnvironmentCustomImageSetupSessionSchema, updateEnvironmentSchema, probeEnvironmentConfigSchema, + startEnvironmentCustomImageSetupSessionSchema, // Company skills companySkillCreateSchema, companySkillFileUpdateSchema, @@ -456,6 +461,36 @@ const refreshExternalObjectsBodySchema = z.object({ objectIds: z.array(z.string().uuid()).max(50).optional(), }).strict(); +const environmentCustomImageCompanyQuerySchema = z.object({ + companyId: z.string().optional(), +}).strict(); + +const disableEnvironmentCustomImageTemplateQuerySchema = + environmentCustomImageCompanyQuerySchema.extend({ + deleteProviderTemplate: z.enum(["true", "false"]).optional(), + }); + +const environmentCustomImageOverviewSchema = z.object({ + activeTemplate: environmentCustomImageTemplateSchema.nullable(), + activeSession: environmentCustomImageSetupSessionSchema.nullable(), + latestSession: environmentCustomImageSetupSessionSchema.nullable(), +}).strict(); + +const environmentCustomImageSetupSessionResultSchema = z.object({ + session: environmentCustomImageSetupSessionSchema, + connectionPayload: z.record(z.string(), z.unknown()).nullable(), +}).strict(); + +const environmentCustomImageSetupSessionFinishResultSchema = + environmentCustomImageSetupSessionResultSchema.extend({ + template: environmentCustomImageTemplateSchema, + }); + +const environmentCustomImageTemplateRollbackResultSchema = z.object({ + activeTemplate: environmentCustomImageTemplateSchema, + supersededTemplate: environmentCustomImageTemplateSchema, +}).strict(); + function paramsSchemaFromPath(routePath: string): z.ZodObject | undefined { const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]); if (names.length === 0) return undefined; @@ -618,6 +653,7 @@ const CREATED_OPERATIONS = new Set([ "POST /api/companies/{companyId}/finance-events", "POST /api/companies/{companyId}/secret-provider-configs", "POST /api/companies/{companyId}/environments", + "POST /api/environments/{environmentId}/custom-image-setup-sessions", "POST /api/companies/{companyId}/goals", "POST /api/companies/{companyId}/labels", "POST /api/issues/{id}/documents/{key}/annotations", @@ -3484,6 +3520,122 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/environments/{environmentId}/custom-image-template", + tags: ["environments"], + summary: "Get the active customImage template and setup status for an environment", + request: { + params: z.object({ environmentId: z.string() }), + query: environmentCustomImageCompanyQuerySchema, + }, + responses: { 200: r.ok(environmentCustomImageOverviewSchema), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environments/{environmentId}/custom-image-setup-sessions", + tags: ["environments"], + summary: "Start an interactive environment customImage setup session", + request: { + params: z.object({ environmentId: z.string() }), + query: environmentCustomImageCompanyQuerySchema, + body: jsonBody(startEnvironmentCustomImageSetupSessionSchema), + }, + responses: { + 201: r.ok(environmentCustomImageSetupSessionResultSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 409: r.conflict, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environment-custom-image-setup-sessions/{sessionId}", + tags: ["environments"], + summary: "Get and refresh an environment customImage setup session", + request: { params: z.object({ sessionId: z.string() }) }, + responses: { + 200: r.ok(environmentCustomImageSetupSessionResultSchema), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environment-custom-image-setup-sessions/{sessionId}/finish", + tags: ["environments"], + summary: "Capture and promote an environment customImage setup session", + request: { + params: z.object({ sessionId: z.string() }), + body: jsonBody(finishEnvironmentCustomImageSetupSessionSchema), + }, + responses: { + 200: r.ok(environmentCustomImageSetupSessionFinishResultSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environment-custom-image-setup-sessions/{sessionId}/cancel", + tags: ["environments"], + summary: "Cancel an environment customImage setup session", + request: { + params: z.object({ sessionId: z.string() }), + body: jsonBody(cancelEnvironmentCustomImageSetupSessionSchema), + }, + responses: { + 200: r.ok(environmentCustomImageSetupSessionSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environments/{environmentId}/custom-image-template/rollback", + tags: ["environments"], + summary: "Roll back an environment customImage template to the previous captured template", + request: { + params: z.object({ environmentId: z.string() }), + query: environmentCustomImageCompanyQuerySchema, + }, + responses: { + 200: r.ok(environmentCustomImageTemplateRollbackResultSchema), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/environments/{environmentId}/custom-image-template", + tags: ["environments"], + summary: "Disable the active environment customImage template", + request: { + params: z.object({ environmentId: z.string() }), + query: disableEnvironmentCustomImageTemplateQuerySchema, + }, + responses: { + 200: r.ok(environmentCustomImageTemplateSchema), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + // ─── Adapters (full) ────────────────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 3d1286d03e..2dfbb36228 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -28,6 +28,7 @@ import { readConfigValueAtPath, writeConfigValueAtPath, } from "./json-schema-secret-refs.js"; +import { resolveActiveEnvironmentCustomImageTemplateForRuntime } from "./environment-custom-image-runtime.js"; const secretRefSchema = z.object({ type: z.literal("secret_ref"), @@ -148,6 +149,13 @@ async function getSandboxProviderConfigSchema( : null; } +export async function resolveSandboxProviderSecretRefPaths( + db: Db, + provider: string, +): Promise> { + return collectSecretRefPaths(await getSandboxProviderConfigSchema(db, provider)); +} + function secretName(input: { environmentName: string; driver: EnvironmentDriver; @@ -550,7 +558,15 @@ export async function resolveEnvironmentDriverConfigForRuntime( db: Db, companyId: string, environment: Pick & Partial>, - context?: { issueId?: string | null; heartbeatRunId?: string | null }, + context?: { + issueId?: string | null; + heartbeatRunId?: string | null; + // Force applying the active custom-image template even without a run/issue + // context. Operator-initiated `Test` probes have no issueId/heartbeatRunId + // but must still resolve the active custom image as prepared runtime + // configuration and tooling so the test reflects what real agent runs use. + applyCustomImageTemplate?: boolean; + }, ): Promise { const parsed = parseEnvironmentDriverConfig(environment); const secrets = secretService(db); @@ -583,19 +599,28 @@ export async function resolveEnvironmentDriverConfigForRuntime( } if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") { + const schema = await getSandboxProviderConfigSchema(db, parsed.config.provider); + const runtimeConfig = await resolveConfigSecretRefsForRuntime({ + db, + companyId, + config: parsed.config as Record, + schema, + context: { + consumerId: environmentId!, + issueId: context?.issueId ?? null, + heartbeatRunId: context?.heartbeatRunId ?? null, + }, + }) as SandboxEnvironmentConfig; return { driver: "sandbox", - config: await resolveConfigSecretRefsForRuntime({ - db, - companyId, - config: parsed.config as Record, - schema: await getSandboxProviderConfigSchema(db, parsed.config.provider), - context: { - consumerId: environmentId!, - issueId: context?.issueId ?? null, - heartbeatRunId: context?.heartbeatRunId ?? null, - }, - }) as SandboxEnvironmentConfig, + config: environmentId && (context?.issueId || context?.heartbeatRunId || context?.applyCustomImageTemplate) + ? await resolveActiveEnvironmentCustomImageTemplateForRuntime(db, { + companyId, + environmentId, + baseConfig: parsed.config, + runtimeConfig, + }) + : runtimeConfig, }; } diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts new file mode 100644 index 0000000000..0f938c1244 --- /dev/null +++ b/server/src/services/environment-custom-image-runtime.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { environmentCustomImageTemplates } from "@paperclipai/db"; +import { + ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS, + type EnvironmentCustomImageTemplate, + type EnvironmentCustomImageTemplateKind, + type SandboxEnvironmentConfig, +} from "@paperclipai/shared"; +import { writeConfigValueAtPath } from "./json-schema-secret-refs.js"; + +type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; + +export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; + +export interface EnvironmentCustomImageRuntimeConfigBinding { + field: string; + unsetFields: string[]; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (isRecord(value)) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +export function readEnvironmentCustomImageTemplateKind( + value: string | null, +): EnvironmentCustomImageTemplateKind { + return (ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS as readonly string[]).includes(value ?? "") + ? value as EnvironmentCustomImageTemplateKind + : "unknown"; +} + +export function defaultEnvironmentCustomImageRuntimeConfigBinding( + templateKind: string | null | undefined, +): EnvironmentCustomImageRuntimeConfigBinding { + const kind = readEnvironmentCustomImageTemplateKind(templateKind ?? null); + if (kind === "snapshot") return { field: "snapshot", unsetFields: ["image"] }; + if (kind === "image") return { field: "image", unsetFields: ["snapshot"] }; + if (kind === "provider_template") return { field: "template", unsetFields: [] }; + return { field: "templateRef", unsetFields: [] }; +} + +function isValidRuntimeConfigBindingField(value: unknown): value is string { + return typeof value === "string" + && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value) + && value !== "provider"; +} + +export function normalizeEnvironmentCustomImageRuntimeConfigBinding( + value: unknown, +): EnvironmentCustomImageRuntimeConfigBinding | null { + if (!isRecord(value) || !isValidRuntimeConfigBindingField(value.field)) return null; + const unsetFields = Array.isArray(value.unsetFields) + ? value.unsetFields.filter((field): field is string => + isValidRuntimeConfigBindingField(field) && field !== value.field, + ) + : []; + return { + field: value.field, + unsetFields: Array.from(new Set(unsetFields)), + }; +} + +export function resolveEnvironmentCustomImageRuntimeConfigBinding(input: { + templateKind: string | null | undefined; + metadata?: Record | null; +}): EnvironmentCustomImageRuntimeConfigBinding { + return normalizeEnvironmentCustomImageRuntimeConfigBinding( + input.metadata?.[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY], + ) ?? defaultEnvironmentCustomImageRuntimeConfigBinding(input.templateKind); +} + +export function fingerprintEnvironmentSandboxProviderConfig( + config: SandboxEnvironmentConfig, + options?: { excludePaths?: Iterable }, +): string { + let normalized = config as Record; + for (const path of options?.excludePaths ?? []) { + normalized = writeConfigValueAtPath(normalized, path, undefined); + } + return createHash("sha256") + .update(stableStringify(normalized)) + .digest("hex"); +} + +export function applyCustomImageTemplateToSandboxConfig( + config: SandboxEnvironmentConfig, + template: Pick, +): SandboxEnvironmentConfig { + if (!template.templateRef) return config; + const next = { ...(config as Record) }; + const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({ + templateKind: template.templateKind, + metadata: template.metadata, + }); + for (const field of binding.unsetFields) { + delete next[field]; + } + next[binding.field] = template.templateRef; + return next as SandboxEnvironmentConfig; +} + +export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { + return { + id: row.id, + companyId: row.companyId, + environmentId: row.environmentId, + provider: row.provider, + templateKind: readEnvironmentCustomImageTemplateKind(row.templateKind), + templateRef: row.templateRef, + sourceTemplateRef: row.sourceTemplateRef ?? null, + sourceEnvironmentConfigFingerprint: row.sourceEnvironmentConfigFingerprint ?? null, + status: row.status, + createdByUserId: row.createdByUserId ?? null, + createdByAgentId: row.createdByAgentId ?? null, + capturedAt: row.capturedAt ?? null, + lastUsedAt: row.lastUsedAt ?? null, + supersededByTemplateId: row.supersededByTemplateId ?? null, + metadata: row.metadata ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( + db: Db, + input: { + companyId: string; + environmentId: string; + baseConfig: SandboxEnvironmentConfig; + runtimeConfig: SandboxEnvironmentConfig; + now?: Date; + }, +): Promise { + const row = await db + .select() + .from(environmentCustomImageTemplates) + .where(and( + eq(environmentCustomImageTemplates.companyId, input.companyId), + eq(environmentCustomImageTemplates.environmentId, input.environmentId), + eq(environmentCustomImageTemplates.provider, input.baseConfig.provider), + eq(environmentCustomImageTemplates.status, "active"), + )) + .orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt)) + .then((rows) => rows[0] ?? null); + if (!row) return input.runtimeConfig; + + const active = environmentCustomImageTemplateFromRow(row); + if (!active.templateRef) return input.runtimeConfig; + + // An active template is an explicit, environment+provider-scoped artifact: the + // captured snapshot/image fully replaces the base image at create time, so it is + // applied whenever present. We deliberately do not gate on a base-config + // fingerprint — the config dialog re-saves the environment right after capture, + // and ordinary resource/lifecycle tweaks (cpu/memory/lease knobs that don't + // affect image identity and are dropped for snapshot creation) would otherwise + // silently discard the captured setup state and provider metadata. Re-running + // setup supersedes the template when the user wants a fresh capture. + const now = input.now ?? new Date(); + await db + .update(environmentCustomImageTemplates) + .set({ lastUsedAt: now, updatedAt: now }) + .where(eq(environmentCustomImageTemplates.id, active.id)); + return applyCustomImageTemplateToSandboxConfig(input.runtimeConfig, active); +} diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts new file mode 100644 index 0000000000..a044d14e7f --- /dev/null +++ b/server/src/services/environment-custom-images.ts @@ -0,0 +1,894 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, inArray, lte } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + environmentCustomImageSetupSessions, + environmentCustomImageTemplates, +} from "@paperclipai/db"; +import { + ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES, + ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES, + type Environment, + type EnvironmentCustomImageSetupConnectionSummary, + type EnvironmentCustomImageSetupSession, + type EnvironmentCustomImageSetupSessionStatus, + type EnvironmentCustomImageTemplate, + type EnvironmentCustomImageTemplateKind, + type SandboxEnvironmentConfig, + redactEnvironmentCustomImageValue, +} from "@paperclipai/shared"; +import type { + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentInteractiveSetupConnectionPayload, + PluginEnvironmentInteractiveSetupSession, + PluginEnvironmentTemplateRefKind, +} from "@paperclipai/plugin-sdk"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import { + parseEnvironmentDriverConfig, + resolveEnvironmentDriverConfigForRuntime, + resolveSandboxProviderSecretRefPaths, + stripSandboxProviderEnvelope, +} from "./environment-config.js"; +import { + resolvePluginExecuteRpcTimeoutMs, + resolvePluginSandboxProviderDriverByKey, +} from "./plugin-environment-driver.js"; +import { environmentService } from "./environments.js"; +import { + fingerprintEnvironmentSandboxProviderConfig, + ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY, + defaultEnvironmentCustomImageRuntimeConfigBinding, + normalizeEnvironmentCustomImageRuntimeConfigBinding, + environmentCustomImageTemplateFromRow, + readEnvironmentCustomImageTemplateKind as readTemplateKind, +} from "./environment-custom-image-runtime.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; + +const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const; +const DEFAULT_SETUP_TTL_SECONDS = 60 * 60; +const DEFAULT_CONNECTION_EXPIRES_IN_MINUTES = 15; + +type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect; + +export interface EnvironmentCustomImageOverview { + activeTemplate: EnvironmentCustomImageTemplate | null; + activeSession: EnvironmentCustomImageSetupSession | null; + latestSession: EnvironmentCustomImageSetupSession | null; +} + +export interface EnvironmentCustomImageSetupSessionResult { + session: EnvironmentCustomImageSetupSession; + connectionPayload: PluginEnvironmentInteractiveSetupConnectionPayload | null; +} + +export interface EnvironmentCustomImageSetupCleanupResult { + scanned: number; + timedOut: number; + failed: number; +} + +function toSession(row: SetupSessionRow): EnvironmentCustomImageSetupSession { + return { + id: row.id, + companyId: row.companyId, + environmentId: row.environmentId, + templateId: row.templateId ?? null, + promotedTemplateId: row.promotedTemplateId ?? null, + provider: row.provider, + providerLeaseId: row.providerLeaseId ?? null, + environmentLeaseId: row.environmentLeaseId ?? null, + status: row.status, + startedByUserId: row.startedByUserId ?? null, + startedByAgentId: row.startedByAgentId ?? null, + baseTemplateRef: row.baseTemplateRef ?? null, + expiresAt: row.expiresAt ?? null, + finishedAt: row.finishedAt ?? null, + failureReason: row.failureReason ?? null, + connectionSummary: row.connectionSummary ?? null, + connectionSecretRef: row.connectionSecretRef ?? null, + metadata: row.metadata ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function readConnectionType(value: string | null | undefined): EnvironmentCustomImageSetupConnectionSummary["type"] { + if ((ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES as readonly string[]).includes(value ?? "")) { + return value as EnvironmentCustomImageSetupConnectionSummary["type"]; + } + return "unknown"; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function toDate(value: string | Date | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function normalizeConnectionSummary( + summary: PluginEnvironmentInteractiveSetupSession["connectionSummary"], +): EnvironmentCustomImageSetupConnectionSummary | null { + if (!summary) return null; + const label = readString((summary as unknown as Record).label); + return { + type: readConnectionType(summary.type), + username: readString(summary.username), + hostRedacted: true, + portRedacted: true, + ...(label ? { label } : {}), + }; +} + +function normalizeProviderMetadata(metadata: Record | null | undefined): Record | null { + if (!metadata) return null; + return redactEnvironmentCustomImageValue(metadata); +} + +function normalizePersistedStatus( + status: string, + fallback: EnvironmentCustomImageSetupSessionStatus = "failed", +): EnvironmentCustomImageSetupSessionStatus { + return (ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES as readonly string[]).includes(status) + ? status as EnvironmentCustomImageSetupSessionStatus + : fallback; +} + +function addSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1000); +} + +function isActiveSetupStatus(status: EnvironmentCustomImageSetupSessionStatus): boolean { + return (ACTIVE_SETUP_STATUSES as readonly string[]).includes(status); +} + +function templateConfigBindingFromDriver(input: { + templateRefKind?: string | null | undefined; + templateConfigBinding?: unknown; +}) { + return normalizeEnvironmentCustomImageRuntimeConfigBinding(input.templateConfigBinding) + ?? defaultEnvironmentCustomImageRuntimeConfigBinding(input.templateRefKind); +} + +function sourceTemplateFromConfig( + config: SandboxEnvironmentConfig, + binding: ReturnType, + templateKind: EnvironmentCustomImageTemplateKind, +): { + sourceTemplateRef: string | null; + sourceTemplateKind: EnvironmentCustomImageTemplateKind | null; +} { + const record = config as Record; + const configuredTemplate = readString(record[binding.field]); + if (configuredTemplate) { + return { sourceTemplateRef: configuredTemplate, sourceTemplateKind: templateKind }; + } + const snapshot = readString(record.snapshot); + if (snapshot) return { sourceTemplateRef: snapshot, sourceTemplateKind: "snapshot" }; + const image = readString(record.image); + if (image) return { sourceTemplateRef: image, sourceTemplateKind: "image" }; + const providerTemplate = readString(record.template); + if (providerTemplate) return { sourceTemplateRef: providerTemplate, sourceTemplateKind: "provider_template" }; + return { sourceTemplateRef: null, sourceTemplateKind: null }; +} + +async function resolveActiveTemplate( + db: Db, + input: { companyId: string; environmentId: string; provider?: string | null }, +): Promise { + const conditions = [ + eq(environmentCustomImageTemplates.companyId, input.companyId), + eq(environmentCustomImageTemplates.environmentId, input.environmentId), + eq(environmentCustomImageTemplates.status, "active"), + ]; + if (input.provider) { + conditions.push(eq(environmentCustomImageTemplates.provider, input.provider)); + } + const row = await db + .select() + .from(environmentCustomImageTemplates) + .where(and(...conditions)) + .orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt)) + .then((rows) => rows[0] ?? null); + return row ? environmentCustomImageTemplateFromRow(row) : null; +} + +export function environmentCustomImageService( + db: Db, + options: { pluginWorkerManager?: PluginWorkerManager } = {}, +) { + const environments = environmentService(db); + + async function getTemplateById(id: string): Promise { + const row = await db + .select() + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.id, id)) + .then((rows) => rows[0] ?? null); + return row ? environmentCustomImageTemplateFromRow(row) : null; + } + + async function getSessionById(id: string): Promise { + const row = await db + .select() + .from(environmentCustomImageSetupSessions) + .where(eq(environmentCustomImageSetupSessions.id, id)) + .then((rows) => rows[0] ?? null); + return row ? toSession(row) : null; + } + + async function getActiveSetupSession(input: { + companyId: string; + environmentId: string; + }): Promise { + const row = await db + .select() + .from(environmentCustomImageSetupSessions) + .where(and( + eq(environmentCustomImageSetupSessions.companyId, input.companyId), + eq(environmentCustomImageSetupSessions.environmentId, input.environmentId), + inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_SETUP_STATUSES]), + )) + .orderBy(desc(environmentCustomImageSetupSessions.createdAt)) + .then((rows) => rows[0] ?? null); + return row ? toSession(row) : null; + } + + async function getLatestSetupSession(input: { + companyId: string; + environmentId: string; + }): Promise { + const row = await db + .select() + .from(environmentCustomImageSetupSessions) + .where(and( + eq(environmentCustomImageSetupSessions.companyId, input.companyId), + eq(environmentCustomImageSetupSessions.environmentId, input.environmentId), + )) + .orderBy(desc(environmentCustomImageSetupSessions.createdAt)) + .then((rows) => rows[0] ?? null); + return row ? toSession(row) : null; + } + + async function requireEnvironment(environmentId: string): Promise { + const environment = await environments.getById(environmentId); + if (!environment) throw notFound("Environment not found"); + return environment; + } + + async function resolveSetupProvider(input: { + companyId: string; + environment: Environment; + requireCapture?: boolean; + requireDelete?: boolean; + }) { + if (!options.pluginWorkerManager) { + throw unprocessable("Environment customImage setup requires a running plugin worker manager."); + } + const parsed = await resolveEnvironmentDriverConfigForRuntime( + db, + input.companyId, + input.environment, + { issueId: null, heartbeatRunId: null }, + ); + if (parsed.driver !== "sandbox") { + throw unprocessable("Environment customImage setup is only supported for sandbox environments."); + } + const provider = parsed.config.provider; + const resolved = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: provider, + workerManager: options.pluginWorkerManager, + requireRunning: true, + }); + if (!resolved) { + throw unprocessable(`Sandbox provider "${provider}" is not ready for customImage setup.`); + } + if (!resolved.driver.supportsInteractiveSetup) { + throw unprocessable(`Sandbox provider "${provider}" does not support interactive setup.`); + } + if (input.requireCapture && !resolved.driver.supportsTemplateCapture) { + throw unprocessable(`Sandbox provider "${provider}" does not support template capture.`); + } + if (input.requireDelete && !resolved.driver.supportsTemplateDelete) { + throw unprocessable(`Sandbox provider "${provider}" does not support template deletion.`); + } + return { + provider, + pluginId: resolved.plugin.id, + driver: resolved.driver, + runtimeConfig: parsed.config, + driverConfig: stripSandboxProviderEnvelope(parsed.config), + }; + } + + async function callProviderStart(input: { + companyId: string; + environment: Environment; + sessionId: string; + expiresAt: Date; + sourceTemplateRef: string | null; + sourceTemplateKind: EnvironmentCustomImageTemplateKind | null; + }): Promise { + const provider = await resolveSetupProvider({ companyId: input.companyId, environment: input.environment }); + return await options.pluginWorkerManager!.call(provider.pluginId, "environmentStartInteractiveSetup", { + driverKey: provider.provider, + companyId: input.companyId, + environmentId: input.environment.id, + issueId: null, + config: provider.driverConfig, + sessionId: input.sessionId, + sourceTemplateRef: input.sourceTemplateRef, + sourceTemplateKind: input.sourceTemplateKind as PluginEnvironmentTemplateRefKind | null, + connectionExpiresInMinutes: DEFAULT_CONNECTION_EXPIRES_IN_MINUTES, + expiresAt: input.expiresAt.toISOString(), + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: provider.driverConfig, + })); + } + + async function callProviderGet(input: { + session: EnvironmentCustomImageSetupSession; + includeConnectionPayload: boolean; + }): Promise { + const environment = await requireEnvironment(input.session.environmentId); + const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment }); + return await options.pluginWorkerManager!.call(provider.pluginId, "environmentGetInteractiveSetup", { + driverKey: provider.provider, + companyId: input.session.companyId, + environmentId: environment.id, + issueId: null, + config: provider.driverConfig, + providerLeaseId: input.session.providerLeaseId, + setupMetadata: input.session.metadata ?? undefined, + includeConnectionPayload: input.includeConnectionPayload, + connectionExpiresInMinutes: DEFAULT_CONNECTION_EXPIRES_IN_MINUTES, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: provider.driverConfig, + })); + } + + async function callProviderCapture(input: { + session: EnvironmentCustomImageSetupSession; + previousTemplate: EnvironmentCustomImageTemplate | null; + }): Promise { + const environment = await requireEnvironment(input.session.environmentId); + const provider = await resolveSetupProvider({ + companyId: input.session.companyId, + environment, + requireCapture: true, + }); + return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCaptureTemplate", { + driverKey: provider.provider, + companyId: input.session.companyId, + environmentId: environment.id, + issueId: null, + config: provider.driverConfig, + providerLeaseId: input.session.providerLeaseId, + setupMetadata: input.session.metadata ?? undefined, + sourceTemplateRef: input.session.baseTemplateRef, + previousTemplateRef: input.previousTemplate?.templateRef ?? null, + templateLabel: `paperclip-${environment.id}-${input.session.id.slice(0, 8)}`, + timeoutMs: typeof provider.driverConfig.timeoutMs === "number" ? provider.driverConfig.timeoutMs : null, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: typeof provider.driverConfig.timeoutMs === "number" ? provider.driverConfig.timeoutMs : undefined, + config: provider.driverConfig, + })); + } + + async function callProviderCancel(input: { + session: EnvironmentCustomImageSetupSession; + reason: string | null; + }): Promise { + const environment = await requireEnvironment(input.session.environmentId); + const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment }); + return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCancelInteractiveSetup", { + driverKey: provider.provider, + companyId: input.session.companyId, + environmentId: environment.id, + issueId: null, + config: provider.driverConfig, + providerLeaseId: input.session.providerLeaseId, + setupMetadata: input.session.metadata ?? undefined, + reason: input.reason, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: provider.driverConfig, + })); + } + + async function callProviderDeleteTemplate(input: { + template: EnvironmentCustomImageTemplate; + reason: string | null; + }) { + if (!input.template.templateRef) { + throw unprocessable("Cannot delete an environment customImage template without a provider template ref."); + } + const environment = await requireEnvironment(input.template.environmentId); + const provider = await resolveSetupProvider({ + companyId: input.template.companyId, + environment, + requireDelete: true, + }); + return await options.pluginWorkerManager!.call(provider.pluginId, "environmentDeleteTemplate", { + driverKey: provider.provider, + companyId: input.template.companyId, + environmentId: environment.id, + issueId: null, + config: provider.driverConfig, + templateRef: input.template.templateRef, + templateKind: input.template.templateKind as PluginEnvironmentTemplateRefKind, + metadata: input.template.metadata ?? undefined, + reason: input.reason, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: provider.driverConfig, + })); + } + + async function updateSessionFromProvider( + sessionId: string, + providerSession: PluginEnvironmentInteractiveSetupSession, + fallbackStatus: EnvironmentCustomImageSetupSessionStatus = "failed", + ): Promise { + const status = normalizePersistedStatus(providerSession.status, fallbackStatus); + const now = new Date(); + const row = await db + .update(environmentCustomImageSetupSessions) + .set({ + providerLeaseId: providerSession.providerLeaseId ?? null, + status, + connectionSummary: normalizeConnectionSummary(providerSession.connectionSummary), + expiresAt: providerSession.expiresAt ? new Date(providerSession.expiresAt) : undefined, + metadata: normalizeProviderMetadata(providerSession.metadata), + failureReason: status === "failed" ? "Provider setup session failed or is missing." : null, + updatedAt: now, + }) + .where(eq(environmentCustomImageSetupSessions.id, sessionId)) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Environment customImage setup session not found"); + return toSession(row); + } + + async function markSessionStatus(input: { + sessionId: string; + status: EnvironmentCustomImageSetupSessionStatus; + failureReason?: string | null; + metadata?: Record | null; + }): Promise { + const now = new Date(); + const row = await db + .update(environmentCustomImageSetupSessions) + .set({ + status: input.status, + failureReason: input.failureReason ?? null, + metadata: input.metadata === undefined ? undefined : normalizeProviderMetadata(input.metadata), + finishedAt: ["promoted", "cancelled", "timed_out", "failed"].includes(input.status) ? now : undefined, + updatedAt: now, + }) + .where(eq(environmentCustomImageSetupSessions.id, input.sessionId)) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Environment customImage setup session not found"); + return toSession(row); + } + + async function cancelSession( + session: EnvironmentCustomImageSetupSession, + reason: string | null, + fallbackStatus: EnvironmentCustomImageSetupSessionStatus, + ): Promise { + if (!isActiveSetupStatus(session.status)) return session; + try { + const cancelled = await callProviderCancel({ session, reason }); + const status = cancelled.status === "missing" + ? fallbackStatus + : normalizePersistedStatus(cancelled.status, fallbackStatus); + return await markSessionStatus({ + sessionId: session.id, + status, + metadata: cancelled.metadata, + failureReason: status === "failed" ? "Provider failed to cancel setup session." : null, + }); + } catch (error) { + return await markSessionStatus({ + sessionId: session.id, + status: "failed", + failureReason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + getOverview: async (input: { + companyId: string; + environmentId: string; + }): Promise => { + await requireEnvironment(input.environmentId); + const [activeTemplate, activeSession, latestSession] = await Promise.all([ + resolveActiveTemplate(db, input), + getActiveSetupSession(input), + getLatestSetupSession(input), + ]); + return { activeTemplate, activeSession, latestSession }; + }, + + getActiveTemplate: async (input: { + companyId: string; + environmentId: string; + provider?: string | null; + }): Promise => resolveActiveTemplate(db, input), + + getSessionById, + + startSetupSession: async (input: { + companyId: string; + environmentId: string; + templateId?: string | null; + ttlSeconds?: number | null; + actor: { userId?: string | null; agentId?: string | null }; + now?: Date; + }): Promise => { + const environment = await requireEnvironment(input.environmentId); + const provider = await resolveSetupProvider({ companyId: input.companyId, environment }); + const activeSession = await getActiveSetupSession(input); + if (activeSession) { + throw conflict("An environment customImage setup session is already active for this environment."); + } + const selectedTemplate = input.templateId + ? await getTemplateById(input.templateId) + : await resolveActiveTemplate(db, { + companyId: input.companyId, + environmentId: input.environmentId, + provider: provider.provider, + }); + if (input.templateId && !selectedTemplate) { + throw notFound("Environment customImage template not found"); + } + if ( + selectedTemplate && + ( + selectedTemplate.companyId !== input.companyId || + selectedTemplate.environmentId !== input.environmentId || + selectedTemplate.provider !== provider.provider || + selectedTemplate.status !== "active" + ) + ) { + throw unprocessable("Setup template must be the active template for this company and environment."); + } + const source = selectedTemplate + ? { + sourceTemplateRef: selectedTemplate.templateRef, + sourceTemplateKind: selectedTemplate.templateKind, + } + : sourceTemplateFromConfig( + provider.runtimeConfig, + templateConfigBindingFromDriver(provider.driver), + readTemplateKind(provider.driver.templateRefKind ?? null), + ); + const now = input.now ?? new Date(); + const ttlSeconds = input.ttlSeconds ?? DEFAULT_SETUP_TTL_SECONDS; + const expiresAt = addSeconds(now, ttlSeconds); + const sessionId = randomUUID(); + const fingerprint = fingerprintEnvironmentSandboxProviderConfig(provider.runtimeConfig); + await db.insert(environmentCustomImageSetupSessions).values({ + id: sessionId, + companyId: input.companyId, + environmentId: input.environmentId, + templateId: selectedTemplate?.id ?? null, + provider: provider.provider, + status: "starting", + startedByUserId: input.actor.userId ?? null, + startedByAgentId: input.actor.agentId ?? null, + baseTemplateRef: source.sourceTemplateRef, + expiresAt, + metadata: { + sourceEnvironmentConfigFingerprint: fingerprint, + }, + createdAt: now, + updatedAt: now, + }); + + try { + const providerSession = await callProviderStart({ + companyId: input.companyId, + environment, + sessionId, + expiresAt, + sourceTemplateRef: source.sourceTemplateRef, + sourceTemplateKind: source.sourceTemplateKind, + }); + const session = await updateSessionFromProvider(sessionId, providerSession); + return { + session, + connectionPayload: providerSession.connectionPayload ?? null, + }; + } catch (error) { + await markSessionStatus({ + sessionId, + status: "failed", + failureReason: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + refreshSetupSession: async (input: { + sessionId: string; + includeConnectionPayload?: boolean; + now?: Date; + }): Promise => { + const session = await getSessionById(input.sessionId); + if (!session) throw notFound("Environment customImage setup session not found"); + const now = input.now ?? new Date(); + const expiresAt = toDate(session.expiresAt); + if (expiresAt && expiresAt.getTime() <= now.getTime() && isActiveSetupStatus(session.status)) { + return { + session: await cancelSession(session, "timed_out", "timed_out"), + connectionPayload: null, + }; + } + if (!isActiveSetupStatus(session.status)) { + return { session, connectionPayload: null }; + } + const providerSession = await callProviderGet({ + session, + includeConnectionPayload: input.includeConnectionPayload ?? false, + }); + return { + session: await updateSessionFromProvider(session.id, providerSession), + connectionPayload: providerSession.connectionPayload ?? null, + }; + }, + + finishSetupSession: async (input: { + sessionId: string; + metadata?: Record; + now?: Date; + }): Promise => { + const session = await getSessionById(input.sessionId); + if (!session) throw notFound("Environment customImage setup session not found"); + const expiresAt = toDate(session.expiresAt); + if (expiresAt && expiresAt.getTime() <= (input.now ?? new Date()).getTime()) { + const timedOut = await cancelSession(session, "timed_out", "timed_out"); + throw conflict("Environment customImage setup session has expired.", { session: timedOut }); + } + if (session.status !== "waiting_for_user" && session.status !== "starting") { + throw conflict(`Cannot finish setup session from status "${session.status}".`); + } + await markSessionStatus({ sessionId: session.id, status: "capturing" }); + const currentActive = await resolveActiveTemplate(db, { + companyId: session.companyId, + environmentId: session.environmentId, + provider: session.provider, + }); + try { + const captured = await callProviderCapture({ session, previousTemplate: currentActive }); + const environment = await requireEnvironment(session.environmentId); + const parsed = parseEnvironmentDriverConfig(environment); + const baseFingerprint = parsed.driver === "sandbox" + ? fingerprintEnvironmentSandboxProviderConfig(parsed.config, { + excludePaths: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + }) + : null; + const provider = await resolveSetupProvider({ + companyId: session.companyId, + environment, + requireCapture: true, + }); + const runtimeConfigBinding = templateConfigBindingFromDriver({ + templateRefKind: captured.templateKind, + templateConfigBinding: provider.driver.templateConfigBinding, + }); + const now = input.now ?? new Date(); + const templateRow = await db.transaction(async (tx) => { + const templateId = randomUUID(); + const supersededRows = await tx + .update(environmentCustomImageTemplates) + .set({ + status: "superseded", + supersededByTemplateId: null, + updatedAt: now, + }) + .where(and( + eq(environmentCustomImageTemplates.companyId, session.companyId), + eq(environmentCustomImageTemplates.environmentId, session.environmentId), + eq(environmentCustomImageTemplates.provider, session.provider), + eq(environmentCustomImageTemplates.status, "active"), + )) + .returning({ id: environmentCustomImageTemplates.id }); + const [created] = await tx + .insert(environmentCustomImageTemplates) + .values({ + id: templateId, + companyId: session.companyId, + environmentId: session.environmentId, + provider: session.provider, + templateKind: readTemplateKind(captured.templateKind), + templateRef: captured.templateRef, + sourceTemplateRef: session.baseTemplateRef, + sourceEnvironmentConfigFingerprint: baseFingerprint, + status: "active", + createdByUserId: session.startedByUserId, + createdByAgentId: session.startedByAgentId, + capturedAt: now, + metadata: normalizeProviderMetadata({ + ...(captured.metadata ?? {}), + ...(input.metadata ? { userMetadata: input.metadata } : {}), + [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding, + }), + createdAt: now, + updatedAt: now, + }) + .returning(); + if (supersededRows.length > 0) { + await tx + .update(environmentCustomImageTemplates) + .set({ + supersededByTemplateId: templateId, + updatedAt: now, + }) + .where(inArray( + environmentCustomImageTemplates.id, + supersededRows.map((row) => row.id), + )); + } + await tx + .update(environmentCustomImageSetupSessions) + .set({ + status: "promoted", + promotedTemplateId: templateId, + finishedAt: now, + metadata: normalizeProviderMetadata({ + ...(session.metadata ?? {}), + capture: captured.metadata ?? {}, + }), + updatedAt: now, + }) + .where(eq(environmentCustomImageSetupSessions.id, session.id)); + return created; + }); + if (!templateRow) throw new Error("Failed to create environment customImage template"); + + const promotedSession = await getSessionById(session.id); + if (!promotedSession) throw notFound("Environment customImage setup session not found"); + try { + await callProviderCancel({ session: promotedSession, reason: "promoted" }); + } catch { + // Promotion succeeded; a later explicit cancel/cleanup can retry provider teardown. + } + return { + session: promotedSession, + template: environmentCustomImageTemplateFromRow(templateRow), + connectionPayload: null, + }; + } catch (error) { + await markSessionStatus({ + sessionId: session.id, + status: "failed", + failureReason: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + cancelSetupSession: async (input: { + sessionId: string; + reason?: string | null; + }): Promise => { + const session = await getSessionById(input.sessionId); + if (!session) throw notFound("Environment customImage setup session not found"); + return await cancelSession(session, input.reason ?? "cancelled", "cancelled"); + }, + + rollbackTemplate: async (input: { + companyId: string; + environmentId: string; + now?: Date; + }): Promise<{ + activeTemplate: EnvironmentCustomImageTemplate; + supersededTemplate: EnvironmentCustomImageTemplate; + }> => { + await requireEnvironment(input.environmentId); + const active = await resolveActiveTemplate(db, input); + if (!active) throw notFound("Active environment customImage template not found"); + const previousRow = await db + .select() + .from(environmentCustomImageTemplates) + .where(and( + eq(environmentCustomImageTemplates.companyId, input.companyId), + eq(environmentCustomImageTemplates.environmentId, input.environmentId), + eq(environmentCustomImageTemplates.provider, active.provider), + eq(environmentCustomImageTemplates.status, "superseded"), + eq(environmentCustomImageTemplates.supersededByTemplateId, active.id), + )) + .orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt)) + .then((rows) => rows[0] ?? null); + if (!previousRow) throw notFound("Previous environment customImage template not found"); + const now = input.now ?? new Date(); + const [supersededRow, activeRow] = await db.transaction(async (tx) => { + const [nextSuperseded] = await tx + .update(environmentCustomImageTemplates) + .set({ + status: "superseded", + supersededByTemplateId: previousRow.id, + updatedAt: now, + }) + .where(eq(environmentCustomImageTemplates.id, active.id)) + .returning(); + const [nextActive] = await tx + .update(environmentCustomImageTemplates) + .set({ + status: "active", + supersededByTemplateId: null, + updatedAt: now, + }) + .where(eq(environmentCustomImageTemplates.id, previousRow.id)) + .returning(); + return [nextSuperseded, nextActive] as const; + }); + if (!supersededRow || !activeRow) throw new Error("Failed to roll back environment customImage template"); + return { + activeTemplate: environmentCustomImageTemplateFromRow(activeRow), + supersededTemplate: environmentCustomImageTemplateFromRow(supersededRow), + }; + }, + + disableTemplate: async (input: { + companyId: string; + environmentId: string; + deleteProviderTemplate?: boolean; + now?: Date; + }): Promise => { + await requireEnvironment(input.environmentId); + const active = await resolveActiveTemplate(db, input); + if (!active) throw notFound("Active environment customImage template not found"); + const now = input.now ?? new Date(); + const row = await db + .update(environmentCustomImageTemplates) + .set({ status: "revoked", updatedAt: now }) + .where(eq(environmentCustomImageTemplates.id, active.id)) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Active environment customImage template not found"); + const template = environmentCustomImageTemplateFromRow(row); + if (input.deleteProviderTemplate) { + await callProviderDeleteTemplate({ template, reason: "disabled" }); + } + return template; + }, + + cleanupExpiredSetupSessions: async (input: { + now?: Date; + limit?: number; + } = {}): Promise => { + const now = input.now ?? new Date(); + const limit = Math.max(1, Math.min(input.limit ?? 25, 100)); + const rows = await db + .select() + .from(environmentCustomImageSetupSessions) + .where(and( + inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_SETUP_STATUSES]), + lte(environmentCustomImageSetupSessions.expiresAt, now), + )) + .limit(limit); + let timedOut = 0; + let failed = 0; + for (const row of rows) { + const session = toSession(row); + const updated = await cancelSession(session, "timed_out", "timed_out"); + if (updated.status === "timed_out") timedOut += 1; + if (updated.status === "failed") failed += 1; + } + return { scanned: rows.length, timedOut, failed }; + }, + }; +} diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index c5323085e5..bf3de1e2e5 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -122,6 +122,13 @@ export interface EnvironmentDriverAcquireInput { * configured default adapter. */ adapterType: string | null; + /** + * Force applying the active custom-image template even when issueId and + * heartbeatRunId are null. Operator-initiated `Test` probes set this so the + * probe uses the operator-prepared custom image for the runtime lease instead + * of the base image, matching what real agent runs do. + */ + applyCustomImageTemplate?: boolean; } export interface EnvironmentDriverReleaseInput { @@ -373,6 +380,7 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, { issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, }); if (parsed.driver !== "ssh") { throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`); @@ -531,6 +539,7 @@ function createSandboxEnvironmentDriver( const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, { issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, }); if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") { throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`); @@ -1448,6 +1457,12 @@ export function environmentRuntimeService( persistedExecutionWorkspace: Pick | null; /** The agent's adapter type for this run (mixed-harness environments). */ adapterType?: string | null; + /** + * Force applying the active custom-image template even for ad-hoc (no + * issue/run) invocations. Operator `Test` probes set this so the runtime + * lease uses the operator-prepared custom image. + */ + applyCustomImageTemplate?: boolean; }): Promise { if (input.environment.status !== "active") { throw new Error(`Environment "${input.environment.name}" is not active.`); @@ -1466,6 +1481,7 @@ export function environmentRuntimeService( executionWorkspaceId: leaseContext.executionWorkspaceId, executionWorkspaceMode: leaseContext.executionWorkspaceMode, adapterType: input.adapterType ?? null, + applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, }); return { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index e968098d72..20da1cef48 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -82,6 +82,13 @@ export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./clo export { companyPortabilityService } from "./company-portability.js"; export { teamsCatalogService } from "./teams-catalog.js"; export { environmentService } from "./environments.js"; +export { + applyCustomImageTemplateToSandboxConfig, + fingerprintEnvironmentSandboxProviderConfig, +} from "./environment-custom-image-runtime.js"; +export { + environmentCustomImageService, +} from "./environment-custom-images.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; export { workspaceOperationService } from "./workspace-operations.js"; export { workspaceFileResourceService } from "./workspace-file-resources.js"; diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index 9e01b696b5..15d6544f83 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -132,6 +132,11 @@ const OPERATION_CAPABILITIES: Record = { "environment.destroyLease": ["environment.drivers.register"], "environment.realizeWorkspace": ["environment.drivers.register"], "environment.execute": ["environment.drivers.register"], + "environment.startInteractiveSetup": ["environment.drivers.register"], + "environment.getInteractiveSetup": ["environment.drivers.register"], + "environment.captureTemplate": ["environment.drivers.register"], + "environment.cancelInteractiveSetup": ["environment.drivers.register"], + "environment.deleteTemplate": ["environment.drivers.register"], }; /** diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 0bc00d1dcc..edc80ed962 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -7,6 +7,15 @@ import type { import type { PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginEnvironmentInteractiveSetupSession, + PluginEnvironmentStartInteractiveSetupParams, + PluginEnvironmentGetInteractiveSetupParams, + PluginEnvironmentCaptureTemplateParams, + PluginEnvironmentCaptureTemplateResult, + PluginEnvironmentCancelInteractiveSetupParams, + PluginEnvironmentCancelInteractiveSetupResult, + PluginEnvironmentDeleteTemplateParams, + PluginEnvironmentDeleteTemplateResult, PluginEnvironmentLease, PluginEnvironmentRealizeWorkspaceParams, PluginEnvironmentRealizeWorkspaceResult, @@ -94,6 +103,13 @@ export async function listReadyPluginEnvironmentDrivers(input: { displayName: driver.displayName, description: driver.description, configSchema: driver.configSchema, + supportsReusableLeases: driver.supportsReusableLeases, + supportsInteractiveSetup: driver.supportsInteractiveSetup, + interactiveSetupConnectionTypes: driver.interactiveSetupConnectionTypes, + supportsTemplateCapture: driver.supportsTemplateCapture, + templateRefKind: driver.templateRefKind, + templateConfigBinding: driver.templateConfigBinding, + supportsTemplateDelete: driver.supportsTemplateDelete, })); }); } @@ -328,6 +344,111 @@ export async function executePluginEnvironmentCommand(input: { ); } +export async function startPluginEnvironmentInteractiveSetup(input: { + db: Db; + workerManager: PluginWorkerManager; + config: PluginEnvironmentConfig; + params: Omit; +}): Promise { + const { plugin } = await resolvePluginEnvironmentDriver({ + db: input.db, + workerManager: input.workerManager, + config: input.config, + }); + return await input.workerManager.call(plugin.id, "environmentStartInteractiveSetup", { + ...input.params, + driverKey: input.config.driverKey, + config: input.config.driverConfig, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: input.config.driverConfig, + })); +} + +export async function getPluginEnvironmentInteractiveSetup(input: { + db: Db; + workerManager: PluginWorkerManager; + config: PluginEnvironmentConfig; + params: Omit; +}): Promise { + const { plugin } = await resolvePluginEnvironmentDriver({ + db: input.db, + workerManager: input.workerManager, + config: input.config, + }); + return await input.workerManager.call(plugin.id, "environmentGetInteractiveSetup", { + ...input.params, + driverKey: input.config.driverKey, + config: input.config.driverConfig, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: input.config.driverConfig, + })); +} + +export async function capturePluginEnvironmentTemplate(input: { + db: Db; + workerManager: PluginWorkerManager; + config: PluginEnvironmentConfig; + params: Omit; +}): Promise { + const { plugin } = await resolvePluginEnvironmentDriver({ + db: input.db, + workerManager: input.workerManager, + config: input.config, + }); + return await input.workerManager.call(plugin.id, "environmentCaptureTemplate", { + ...input.params, + driverKey: input.config.driverKey, + config: input.config.driverConfig, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: input.params.timeoutMs ?? undefined, + config: input.config.driverConfig, + })); +} + +export async function cancelPluginEnvironmentInteractiveSetup(input: { + db: Db; + workerManager: PluginWorkerManager; + config: PluginEnvironmentConfig; + params: Omit; +}): Promise { + const { plugin } = await resolvePluginEnvironmentDriver({ + db: input.db, + workerManager: input.workerManager, + config: input.config, + }); + return await input.workerManager.call(plugin.id, "environmentCancelInteractiveSetup", { + ...input.params, + driverKey: input.config.driverKey, + config: input.config.driverConfig, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: input.config.driverConfig, + })); +} + +export async function deletePluginEnvironmentTemplate(input: { + db: Db; + workerManager: PluginWorkerManager; + config: PluginEnvironmentConfig; + params: Omit; +}): Promise { + const { plugin } = await resolvePluginEnvironmentDriver({ + db: input.db, + workerManager: input.workerManager, + config: input.config, + }); + return await input.workerManager.call(plugin.id, "environmentDeleteTemplate", { + ...input.params, + driverKey: input.config.driverKey, + config: input.config.driverConfig, + }, resolvePluginExecuteRpcTimeoutMs({ + requestedTimeoutMs: undefined, + config: input.config.driverConfig, + })); +} + const RPC_OVERHEAD_BUFFER_MS = 30_000; export function resolvePluginExecuteRpcTimeoutMs(input: { diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 73f0cf2b46..0d1a99b80f 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -1,6 +1,44 @@ -import type { Environment, EnvironmentCapabilities, EnvironmentLease, EnvironmentProbeResult } from "@paperclipai/shared"; +import type { + CancelEnvironmentCustomImageSetupSession, + Environment, + EnvironmentCapabilities, + EnvironmentLease, + EnvironmentProbeResult, + EnvironmentCustomImageSetupSession, + EnvironmentCustomImageTemplate, + FinishEnvironmentCustomImageSetupSession, + StartEnvironmentCustomImageSetupSession, +} from "@paperclipai/shared"; import { api } from "./client"; +export interface EnvironmentCustomImageOverview { + activeTemplate: EnvironmentCustomImageTemplate | null; + activeSession: EnvironmentCustomImageSetupSession | null; + latestSession: EnvironmentCustomImageSetupSession | null; +} + +export interface EnvironmentCustomImageConnectionPayload { + type: string; + command?: string | null; + token?: string | null; + expiresAt?: string | null; + metadata?: Record | null; +} + +export interface EnvironmentCustomImageSetupSessionResult { + session: EnvironmentCustomImageSetupSession; + connectionPayload: EnvironmentCustomImageConnectionPayload | null; +} + +export interface EnvironmentCustomImageFinishResult extends EnvironmentCustomImageSetupSessionResult { + template: EnvironmentCustomImageTemplate; +} + +export interface EnvironmentCustomImageRollbackResult { + activeTemplate: EnvironmentCustomImageTemplate; + supersededTemplate: EnvironmentCustomImageTemplate; +} + export const environmentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/environments`), capabilities: (companyId: string) => @@ -29,4 +67,50 @@ export const environmentsApi = { config?: Record; metadata?: Record | null; }) => api.post(`/companies/${companyId}/environments/probe-config`, body), + customImageTemplate: (environmentId: string, companyId: string) => + api.get( + `/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}`, + ), + startCustomImageSetupSession: ( + environmentId: string, + companyId: string, + body: StartEnvironmentCustomImageSetupSession = {}, + ) => + api.post( + `/environments/${environmentId}/custom-image-setup-sessions?companyId=${encodeURIComponent(companyId)}`, + body, + ), + customImageSetupSession: (sessionId: string) => + api.get( + `/environment-custom-image-setup-sessions/${sessionId}`, + ), + finishCustomImageSetupSession: ( + sessionId: string, + body: FinishEnvironmentCustomImageSetupSession = {}, + ) => + api.post( + `/environment-custom-image-setup-sessions/${sessionId}/finish`, + body, + ), + cancelCustomImageSetupSession: ( + sessionId: string, + body: CancelEnvironmentCustomImageSetupSession = {}, + ) => + api.post( + `/environment-custom-image-setup-sessions/${sessionId}/cancel`, + body, + ), + rollbackCustomImageTemplate: (environmentId: string, companyId: string) => + api.post( + `/environments/${environmentId}/custom-image-template/rollback?companyId=${encodeURIComponent(companyId)}`, + {}, + ), + disableCustomImageTemplate: ( + environmentId: string, + companyId: string, + options: { deleteProviderTemplate?: boolean } = {}, + ) => + api.delete( + `/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`, + ), }; diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 2625ecaa49..29285804d0 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -454,6 +454,11 @@ describe("IssueChatThread", () => { const execCommand = vi.fn(() => true); const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext"); + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: false, + }); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: clipboardWrite }, @@ -502,7 +507,7 @@ describe("IssueChatThread", () => { await Promise.resolve(); }); - expect(clipboardWrite).toHaveBeenCalledWith("Copy this comment"); + expect(clipboardWrite).not.toHaveBeenCalled(); expect(execCommand).toHaveBeenCalledWith("copy"); act(() => { @@ -521,6 +526,12 @@ describe("IssueChatThread", () => { // @ts-expect-error test cleanup for optional browser API delete document.execCommand; } + if (originalSecureContext) { + Object.defineProperty(window, "isSecureContext", originalSecureContext); + } else { + // @ts-expect-error test cleanup for optional browser API + delete window.isSecureContext; + } } }); diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index a1173df4bc..1620460817 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -286,6 +286,11 @@ describe("IssueChatThread system notice routing", () => { it("shows copy-link feedback on the link button only", async () => { const writeText = vi.fn(async () => undefined); + const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext"); + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: true, + }); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, @@ -320,6 +325,12 @@ describe("IssueChatThread system notice routing", () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining("#comment-comment-copy-link")); expect(copyLink.querySelector(".lucide-check")).not.toBeNull(); expect(copyText.querySelector(".lucide-check")).toBeNull(); + if (originalSecureContext) { + Object.defineProperty(window, "isSecureContext", originalSecureContext); + } else { + // @ts-expect-error test cleanup for optional browser API + delete window.isSecureContext; + } }); it("labels system notice source as Paperclip when no run agent can be resolved", () => { diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 9ea18a6189..6033692dbd 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -40,7 +40,7 @@ function makeArtifact(overrides: Partial = {}): CompanyArtifact issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" }, project: { id: "proj-1", name: "Paperclip App" }, createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, - updatedAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", href: "/issues/PAP-10306#attachment-art-1", ...overrides, }; diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts new file mode 100644 index 0000000000..2921c39e9e --- /dev/null +++ b/ui/src/lib/clipboard.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +function installDocumentStub(execCommand: () => boolean) { + const textarea = { + value: "", + style: {} as Record, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + }; + const doc = { + createElement: vi.fn(() => textarea), + body: { appendChild: vi.fn(), removeChild: vi.fn() }, + activeElement: null, + getSelection: vi.fn(() => null), + execCommand: vi.fn(execCommand), + }; + vi.stubGlobal("document", doc); + return { doc, textarea }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("copyTextToClipboard", () => { + it("uses the async Clipboard API in a secure context", async () => { + const writeText = vi.fn(async () => {}); + vi.stubGlobal("window", { isSecureContext: true }); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const { doc } = installDocumentStub(() => true); + + await copyTextToClipboard("ssh agent@host"); + + expect(writeText).toHaveBeenCalledWith("ssh agent@host"); + expect(doc.execCommand).not.toHaveBeenCalled(); + }); + + it("falls back to execCommand on a non-secure context even when clipboard exists", async () => { + // Over plain HTTP the Clipboard API can be present but silently no-op, so we + // must not trust it and should write via the execCommand path instead. + const writeText = vi.fn(async () => {}); + vi.stubGlobal("window", { isSecureContext: false }); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const { doc, textarea } = installDocumentStub(() => true); + + await copyTextToClipboard("ssh agent@host"); + + expect(writeText).not.toHaveBeenCalled(); + expect(textarea.value).toBe("ssh agent@host"); + expect(textarea.focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(textarea.select).toHaveBeenCalled(); + expect(textarea.setSelectionRange).toHaveBeenCalledWith(0, "ssh agent@host".length); + expect(doc.execCommand).toHaveBeenCalledWith("copy"); + // Regression guard: Safari silently no-ops execCommand copy from a + // `readonly` or `opacity: 0` textarea, so the fallback must use neither. + expect(textarea.setAttribute).not.toHaveBeenCalledWith("readonly", expect.anything()); + expect(textarea.style.opacity).toBeUndefined(); + }); + + it("throws when the execCommand fallback reports failure", async () => { + vi.stubGlobal("window", { isSecureContext: false }); + vi.stubGlobal("navigator", {}); + installDocumentStub(() => false); + + await expect(copyTextToClipboard("x")).rejects.toThrow(); + }); +}); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index 168d329051..56b9fe7635 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -1,5 +1,10 @@ export async function copyTextToClipboard(text: string): Promise { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + // The async Clipboard API is only reliable in a secure context. Over plain + // HTTP on a non-localhost host (e.g. a Tailscale name) `writeText` may resolve + // without actually writing, so gate on `isSecureContext` and otherwise fall + // through to the execCommand path below. + const isSecure = typeof window === "undefined" || window.isSecureContext; + if (isSecure && typeof navigator !== "undefined" && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; @@ -12,20 +17,32 @@ export async function copyTextToClipboard(text: string): Promise { throw new Error("Clipboard unavailable"); } + // Mirror the proven CopyText fallback: a plain off-screen textarea. Browsers + // (notably Safari) refuse to copy from elements that are `readonly`, zero-size, + // or `opacity: 0` — execCommand silently no-ops while the toast still reports + // success — so keep the element simple and just push it off-screen. const textarea = document.createElement("textarea"); textarea.value = text; - textarea.readOnly = true; + textarea.setAttribute("aria-hidden", "true"); + textarea.tabIndex = -1; textarea.style.position = "fixed"; - textarea.style.top = "-9999px"; + textarea.style.top = "0"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); + const previouslyFocused = document.activeElement as (Element & { + focus?: (options?: FocusOptions) => void; + }) | null; try { + textarea.focus({ preventScroll: true }); textarea.select(); - textarea.setSelectionRange(0, textarea.value.length); + textarea.setSelectionRange(0, text.length); const success = document.execCommand("copy"); if (!success) throw new Error("execCommand copy failed"); } finally { document.body.removeChild(textarea); + if (previouslyFocused !== document.activeElement && typeof previouslyFocused?.focus === "function") { + previouslyFocused.focus({ preventScroll: true }); + } } } diff --git a/ui/src/lib/duplicate-agent-payload.test.ts b/ui/src/lib/duplicate-agent-payload.test.ts index e024042c02..dd86af7a50 100644 --- a/ui/src/lib/duplicate-agent-payload.test.ts +++ b/ui/src/lib/duplicate-agent-payload.test.ts @@ -16,7 +16,7 @@ const baseAgent: AgentDetail = { capabilities: "Builds product features.", adapterType: "codex_local", adapterConfig: { - model: "gpt-5.3-codex", + model: "gpt-5.5", instructionsBundleMode: "managed", instructionsRootPath: "/tmp/original/instructions", instructionsEntryFile: "AGENTS.md", @@ -68,7 +68,7 @@ describe("duplicate agent payload", () => { reportsTo: "manager-1", capabilities: "Builds product features.", adapterType: "codex_local", - adapterConfig: { model: "gpt-5.3-codex" }, + adapterConfig: { model: "gpt-5.5" }, runtimeConfig: { heartbeat: { enabled: true } }, defaultEnvironmentId: "environment-1", budgetMonthlyCents: 500, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index af61062adb..d4ca9c06df 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -161,6 +161,10 @@ export const queryKeys = { environments: { list: (companyId: string) => ["environments", companyId] as const, capabilities: (companyId: string) => ["environment-capabilities", companyId] as const, + customImageTemplate: (companyId: string, environmentId: string) => + ["environments", companyId, environmentId, "custom-image-template"] as const, + customImageSetupSession: (sessionId: string) => + ["environment-custom-image-setup-sessions", sessionId] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index 7d36053dbb..a5fa24b4c9 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -154,6 +154,10 @@ const environmentCapabilities: EnvironmentCapabilities = { supportsUnsavedProbe: true, supportsRunExecution: true, supportsReusableLeases: true, + supportsInteractiveSetup: false, + interactiveSetupConnectionTypes: [], + supportsTemplateCapture: false, + supportsTemplateDelete: false, displayName: "Fake", source: "builtin", }, @@ -163,6 +167,10 @@ const environmentCapabilities: EnvironmentCapabilities = { supportsUnsavedProbe: true, supportsRunExecution: true, supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, displayName: "Daytona", source: "plugin", }, diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 6036489ce4..7305d6d21d 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -15,6 +15,13 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ update: vi.fn(), remove: vi.fn(), setDefault: vi.fn(), + customImageTemplate: vi.fn(), + startCustomImageSetupSession: vi.fn(), + customImageSetupSession: vi.fn(), + finishCustomImageSetupSession: vi.fn(), + cancelCustomImageSetupSession: vi.fn(), + rollbackCustomImageTemplate: vi.fn(), + disableCustomImageTemplate: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ get: vi.fn(), @@ -74,6 +81,20 @@ async function flushReact() { } } +async function waitForAssertion(assertion: () => void) { + let lastError: unknown; + for (let i = 0; i < 20; i += 1) { + await flushReact(); + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + function testProviderButtons(container: HTMLElement): HTMLButtonElement[] { return Array.from(container.querySelectorAll("button")).filter((button) => { const label = button.textContent?.trim(); @@ -85,22 +106,114 @@ function findButton(root: ParentNode, label: string): HTMLButtonElement | undefi return Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === label); } +function editButtons(root: ParentNode): HTMLButtonElement[] { + return Array.from(root.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Edit"); +} + +function click(element: Element | null | undefined) { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); +} + function getOpenDialog(): HTMLElement | null { return document.body.querySelector("[role='dialog']"); } +function createSession(overrides: Record = {}) { + return { + id: "session-1", + companyId: "company-1", + environmentId: "env-1", + templateId: null, + promotedTemplateId: null, + provider: "daytona", + providerLeaseId: "lease-redacted", + environmentLeaseId: null, + status: "waiting_for_user", + startedByUserId: "user-1", + startedByAgentId: null, + baseTemplateRef: null, + expiresAt: "2026-06-25T21:00:00.000Z", + finishedAt: null, + failureReason: null, + connectionSummary: { + type: "ssh", + username: "sandbox", + hostRedacted: true, + portRedacted: true, + }, + connectionSecretRef: "secret-redacted", + metadata: null, + createdAt: "2026-06-25T20:00:00.000Z", + updatedAt: "2026-06-25T20:00:00.000Z", + ...overrides, + }; +} + +function createTemplate(overrides: Record = {}) { + return { + id: "template-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "redacted-template-ref", + sourceTemplateRef: null, + sourceEnvironmentConfigFingerprint: "fingerprint", + status: "active", + createdByUserId: "user-1", + createdByAgentId: null, + capturedAt: "2026-06-25T20:00:00.000Z", + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: "2026-06-25T20:00:00.000Z", + updatedAt: "2026-06-25T20:00:00.000Z", + ...overrides, + }; +} + describe("CompanyEnvironments — test provider button", () => { let container: HTMLDivElement; + let root: ReturnType | null; let probeResolvers: Map void>; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + root = null; probeResolvers = new Map(); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true }); mockEnvironmentsApi.capabilities.mockResolvedValue({ adapters: [], sandboxProviders: {} }); mockSecretsApi.list.mockResolvedValue([]); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: null, + latestSession: null, + }); + mockEnvironmentsApi.startCustomImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "ssh", command: "ssh sandbox@setup.example.invalid" }, + }); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "ssh", command: "ssh sandbox@setup.example.invalid" }, + }); + mockEnvironmentsApi.finishCustomImageSetupSession.mockResolvedValue({ + session: createSession({ status: "promoted", promotedTemplateId: "template-1", finishedAt: "2026-06-25T20:10:00.000Z" }), + template: createTemplate(), + connectionPayload: null, + }); + mockEnvironmentsApi.cancelCustomImageSetupSession.mockResolvedValue( + createSession({ status: "cancelled", finishedAt: "2026-06-25T20:10:00.000Z" }), + ); + mockEnvironmentsApi.rollbackCustomImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-previous" }), + supersededTemplate: createTemplate({ id: "template-current", status: "superseded" }), + }); + mockEnvironmentsApi.disableCustomImageTemplate.mockResolvedValue( + createTemplate({ status: "revoked" }), + ); mockEnvironmentsApi.list.mockResolvedValue([ { id: "env-1", name: "Alpha", driver: "sandbox", description: null, config: { provider: "e2b" } }, { id: "env-2", name: "Beta", driver: "sandbox", description: null, config: { provider: "e2b" } }, @@ -132,17 +245,19 @@ describe("CompanyEnvironments — test provider button", () => { }); afterEach(() => { + root?.unmount(); + root = null; container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); }); it("shows the testing state only on the clicked environment's button", async () => { - const root = createRoot(container); + root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root.render( + root!.render( @@ -172,11 +287,11 @@ describe("CompanyEnvironments — test provider button", () => { }); it("keeps the second environment's testing state when an earlier probe settles", async () => { - const root = createRoot(container); + root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root.render( + root!.render( @@ -208,11 +323,11 @@ describe("CompanyEnvironments — test provider button", () => { }); it("opens the add-environment form in a dialog and closes it on cancel", async () => { - const root = createRoot(container); + root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root.render( + root!.render( @@ -238,11 +353,11 @@ describe("CompanyEnvironments — test provider button", () => { }); it("opens the edit form in a dialog with existing values and closes after save", async () => { - const root = createRoot(container); + root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { - root.render( + root!.render( @@ -274,4 +389,275 @@ describe("CompanyEnvironments — test provider button", () => { ); expect(getOpenDialog()).toBeNull(); }); + + it("shows image setup controls only for providers advertising setup and capture support", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + { id: "env-2", name: "E2B", driver: "sandbox", description: null, config: { provider: "e2b" } }, + { id: "env-3", name: "Policy", driver: "sandbox", description: null, config: { provider: "policy" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + e2b: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: false, + interactiveSetupConnectionTypes: [], + supportsTemplateCapture: false, + supportsTemplateDelete: false, + displayName: "E2B", + }, + policy: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: false, + supportsTemplateDelete: false, + displayName: "Policy Sandbox", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + // Daytona supports setup + capture → "Configure image" in its config dialog. + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Configure image"); + }); + expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + await act(async () => click(findButton(document.body, "Cancel"))); + await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); + + // E2B does not advertise interactive setup. + await act(async () => click(editButtons(container)[1])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Unsupported provider"); + }); + expect(getOpenDialog()?.textContent).not.toContain("Configure image"); + await act(async () => click(findButton(document.body, "Cancel"))); + await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); + + // Provider advertises setup but cannot capture an image. + await act(async () => click(editButtons(container)[2])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Setup capture unavailable"); + }); + expect(getOpenDialog()?.textContent).not.toContain("Configure image"); + }); + + it("shows a live connect command and removes it after cancellation", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const command = "ssh sandbox@setup.example.invalid -p 2222"; + let activeSession: ReturnType | null = createSession(); + let latestSession: ReturnType | null = activeSession; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }); + mockEnvironmentsApi.customImageTemplate.mockImplementation(async () => ({ + activeTemplate: null, + activeSession, + latestSession, + })); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "ssh", command }, + }); + mockEnvironmentsApi.cancelCustomImageSetupSession.mockImplementation(async () => { + activeSession = null; + latestSession = createSession({ status: "cancelled", finishedAt: "2026-06-25T20:10:00.000Z" }); + return latestSession; + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain(command); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Cancel"))); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Setup cancelled"); + }); + + expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith( + "session-1", + { reason: "operator cancelled" }, + ); + expect(getOpenDialog()?.textContent).not.toContain(command); + }); + + it("does not render connect details when an active session refreshes as expired", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const command = "ssh sandbox@setup.example.invalid -p 2222"; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: createSession(), + latestSession: createSession(), + }); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession({ status: "timed_out", finishedAt: "2026-06-25T20:10:00.000Z" }), + connectionPayload: { type: "ssh", command }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Setup expired"); + }); + expect(getOpenDialog()?.textContent).not.toContain(command); + }); + + it("shows active template controls for refresh, rollback, and disable", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog(); + expect(dialog?.textContent).toContain("Active template"); + expect(findButton(dialog!, "Refresh")).toBeTruthy(); + expect(findButton(dialog!, "Rollback")).toBeTruthy(); + expect(findButton(dialog!, "Disable")).toBeTruthy(); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Refresh"))); + await flushReact(); + + expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( + "env-1", + "company-1", + { templateId: "template-active" }, + ); + }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 0b74ee2497..b405d0fc28 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1,12 +1,19 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Play, RefreshCw, RotateCcw, Trash2, X } from "lucide-react"; import { type EnvBinding, type Environment, + type EnvironmentProviderCapability, type EnvironmentProbeResult, + type EnvironmentCustomImageSetupSession, type JsonSchema, } from "@paperclipai/shared"; -import { environmentsApi } from "@/api/environments"; +import { + environmentsApi, + type EnvironmentCustomImageConnectionPayload, + type EnvironmentCustomImageSetupSessionResult, +} from "@/api/environments"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; import { Button } from "@/components/ui/button"; @@ -164,6 +171,421 @@ function summarizeSandboxConfig(config: Record): string | null return null; } +const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = new Set([ + "starting", + "waiting_for_user", + "capturing", +]); + +function isActiveCustomImageSetupSession(session: EnvironmentCustomImageSetupSession | null | undefined) { + return Boolean(session && ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES.has(session.status)); +} + +function readEnvironmentSandboxProvider(environment: Environment): string | null { + return environment.driver === "sandbox" && typeof environment.config.provider === "string" + ? environment.config.provider + : null; +} + +function formatDateTime(value: string | Date | null | undefined): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toLocaleString(); +} + +function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null { + return typeof payload?.command === "string" && payload.command.trim().length > 0 + ? payload.command + : null; +} + +function capabilityState(capability: EnvironmentProviderCapability | null | undefined) { + if (!capability || capability.status !== "supported" || !capability.supportsInteractiveSetup) { + return { + kind: "unsupported" as const, + label: "Unsupported provider", + reason: "This provider does not advertise interactive template setup.", + }; + } + + if (!capability.supportsTemplateCapture) { + return { + kind: "capture_unavailable" as const, + label: "Setup capture unavailable", + reason: "This provider advertises setup, but image capture is unavailable.", + }; + } + + return { + kind: "supported" as const, + label: "Template setup", + reason: null, + }; +} + +function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"]) { + switch (status) { + case "starting": + return "Setup starting"; + case "waiting_for_user": + return "Setup running"; + case "capturing": + return "Capturing template"; + case "promoted": + return "Template captured"; + case "cancelled": + return "Setup cancelled"; + case "timed_out": + return "Setup expired"; + case "failed": + return "Setup failed"; + default: + return "Setup status"; + } +} + +function EnvironmentImageTemplatePanel({ + companyId, + environment, + providerCapability, + providerDisplayName, +}: { + companyId: string; + environment: Environment; + providerCapability: EnvironmentProviderCapability | null | undefined; + providerDisplayName: string; +}) { + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const state = capabilityState(providerCapability); + const overviewKey = queryKeys.environments.customImageTemplate(companyId, environment.id); + + const overviewQuery = useQuery({ + queryKey: overviewKey, + queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId), + enabled: state.kind === "supported", + retry: false, + }); + + const activeSessionId = overviewQuery.data?.activeSession?.id ?? null; + const sessionQuery = useQuery({ + queryKey: activeSessionId + ? queryKeys.environments.customImageSetupSession(activeSessionId) + : ["environment-custom-image-setup-sessions", "none", environment.id], + queryFn: () => environmentsApi.customImageSetupSession(activeSessionId!), + enabled: Boolean(activeSessionId && isActiveCustomImageSetupSession(overviewQuery.data?.activeSession)), + retry: false, + }); + + function setSessionResult(result: EnvironmentCustomImageSetupSessionResult) { + queryClient.setQueryData( + queryKeys.environments.customImageSetupSession(result.session.id), + result, + ); + } + + function invalidateOverview() { + void queryClient.invalidateQueries({ queryKey: overviewKey }); + } + + const startSetupMutation = useMutation({ + mutationFn: (input: { templateId?: string | null } = {}) => + environmentsApi.startCustomImageSetupSession(environment.id, companyId, { + templateId: input.templateId ?? null, + }), + onSuccess: (result) => { + queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ + activeTemplate: current?.activeTemplate ?? null, + activeSession: result.session, + latestSession: result.session, + })); + setSessionResult(result); + invalidateOverview(); + pushToast({ + title: "Setup session started", + body: "Connect details are available while the session is active.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to start setup", + body: error instanceof Error ? error.message : "Setup session could not be started.", + tone: "error", + }); + }, + }); + + const finishSetupMutation = useMutation({ + mutationFn: (sessionId: string) => environmentsApi.finishCustomImageSetupSession(sessionId, {}), + onSuccess: (result) => { + queryClient.setQueryData(overviewKey, { + activeTemplate: result.template, + activeSession: null, + latestSession: result.session, + }); + setSessionResult({ session: result.session, connectionPayload: null }); + invalidateOverview(); + pushToast({ + title: "Template captured", + body: "Future runs can use the promoted template.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to capture template", + body: error instanceof Error ? error.message : "Template capture failed.", + tone: "error", + }); + }, + }); + + const cancelSetupMutation = useMutation({ + mutationFn: (sessionId: string) => + environmentsApi.cancelCustomImageSetupSession(sessionId, { reason: "operator cancelled" }), + onSuccess: (session) => { + queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ + activeTemplate: current?.activeTemplate ?? null, + activeSession: null, + latestSession: session, + })); + setSessionResult({ session, connectionPayload: null }); + invalidateOverview(); + pushToast({ + title: "Setup cancelled", + body: "The active template was not changed.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to cancel setup", + body: error instanceof Error ? error.message : "Setup session could not be cancelled.", + tone: "error", + }); + }, + }); + + const rollbackTemplateMutation = useMutation({ + mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId), + onSuccess: (result) => { + queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ + activeTemplate: result.activeTemplate, + activeSession: null, + latestSession: current?.latestSession ?? null, + })); + invalidateOverview(); + pushToast({ + title: "Template rolled back", + body: "Future runs will use the previous template.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to roll back template", + body: error instanceof Error ? error.message : "Rollback failed.", + tone: "error", + }); + }, + }); + + const disableTemplateMutation = useMutation({ + mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId), + onSuccess: (template) => { + queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ + activeTemplate: null, + activeSession: null, + latestSession: current?.latestSession ?? null, + })); + invalidateOverview(); + pushToast({ + title: "Template disabled", + body: "Future runs will use the base provider configuration.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to disable template", + body: error instanceof Error ? error.message : "Disable failed.", + tone: "error", + }); + }, + }); + + if (state.kind !== "supported") { + return ( +
+
{state.label}
+
{state.reason}
+
+ ); + } + + if (overviewQuery.isLoading) { + return ( +
+ Loading template setup... +
+ ); + } + + if (overviewQuery.isError) { + return ( +
+ {overviewQuery.error instanceof Error ? overviewQuery.error.message : "Template setup could not be loaded."} +
+ ); + } + + const overview = overviewQuery.data; + const activeTemplate = overview?.activeTemplate ?? null; + const refreshedSession = sessionQuery.data?.session ?? null; + const session = refreshedSession ?? overview?.activeSession ?? null; + const latestSession = !isActiveCustomImageSetupSession(session) + ? session ?? overview?.latestSession ?? null + : overview?.latestSession ?? null; + const connectionPayload = session?.status === "waiting_for_user" + ? sessionQuery.data?.connectionPayload ?? null + : null; + const connectionCommand = readConnectionCommand(connectionPayload); + const sessionExpiresAt = formatDateTime(connectionPayload?.expiresAt ?? session?.expiresAt ?? null); + const capturedAt = formatDateTime(activeTemplate?.capturedAt ?? activeTemplate?.createdAt ?? null); + const lastUsedAt = formatDateTime(activeTemplate?.lastUsedAt ?? null); + const isMutating = + startSetupMutation.isPending || + finishSetupMutation.isPending || + cancelSetupMutation.isPending || + rollbackTemplateMutation.isPending || + disableTemplateMutation.isPending; + + if (session && isActiveCustomImageSetupSession(session)) { + const isCapturing = session.status === "capturing"; + return ( +
+
+
+
{sessionStatusCopy(session.status)}
+
+ {providerDisplayName}{sessionExpiresAt ? ` · expires ${sessionExpiresAt}` : ""} +
+
+
+ + +
+
+ {session.status === "waiting_for_user" && connectionCommand ? ( +
+ + {connectionCommand} + +
+ ) : null} + {session.status === "waiting_for_user" && !connectionCommand ? ( +
+ Connection details are not available yet. +
+ ) : null} + {session.failureReason ? ( +
{session.failureReason}
+ ) : null} +
+ ); + } + + if (activeTemplate) { + return ( +
+
+
+
Active template
+
+ {providerDisplayName} · {activeTemplate.templateKind} + {capturedAt ? ` · captured ${capturedAt}` : ""} + {lastUsedAt ? ` · last used ${lastUsedAt}` : ""} +
+
+
+ + + +
+
+
+ ); + } + + return ( +
+
+
+
Not configured
+
+ {latestSession + ? sessionStatusCopy(latestSession.status) + : `Capture a custom ${providerDisplayName} image with your tools already logged in.`} +
+ {latestSession?.failureReason ? ( +
{latestSession.failureReason}
+ ) : null} +
+ +
+
+ ); +} + export function CompanyEnvironments() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -470,6 +892,14 @@ export function CompanyEnvironments() { Object.keys(sandboxConfigErrors).length === 0); const savedEnvironments = environments ?? []; + const editingEnvironment = editingEnvironmentId + ? savedEnvironments.find((environment) => environment.id === editingEnvironmentId) ?? null + : null; + const editingSandboxProvider = editingEnvironment ? readEnvironmentSandboxProvider(editingEnvironment) : null; + const editingSandboxCapability = editingSandboxProvider + ? environmentCapabilities?.sandboxProviders?.[editingSandboxProvider] + : null; + const editingSandboxDisplayName = editingSandboxCapability?.displayName ?? editingSandboxProvider ?? "sandbox"; const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment)); const instanceDefaultEnvironmentId = normalizeNonLocalEnvironmentId( instanceSettings?.defaultEnvironmentId ?? null, @@ -526,6 +956,12 @@ export function CompanyEnvironments() { {savedEnvironments.map((environment) => { const probe = probeResults[environment.id] ?? null; const isEditing = editingEnvironmentId === environment.id; + const sandboxProvider = readEnvironmentSandboxProvider(environment); + const sandboxProviderCapability = sandboxProvider + ? environmentCapabilities?.sandboxProviders?.[sandboxProvider] + : null; + const sandboxProviderDisplayName = + sandboxProviderCapability?.displayName ?? sandboxProvider ?? "sandbox"; return (
{(() => { - const provider = - typeof environment.config.provider === "string" ? environment.config.provider : "sandbox"; - const displayName = - environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider; const summary = summarizeSandboxConfig(environment.config as Record); - return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`; + return `${sandboxProviderDisplayName} sandbox provider${summary ? ` · ${summary}` : ""}`; })()}
) : ( @@ -693,7 +1125,7 @@ export function CompanyEnvironments() { onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))} /> - + ) : null} + {editingEnvironment && + editingEnvironment.driver === "sandbox" && + environmentForm.driver === "sandbox" ? ( +
+
Custom image
+
+ Start a setup sandbox, SSH in to customize the instance, then capture the + running machine as a reusable image for future runs. +
+ +
+ ) : null} + { const execCommand = vi.fn(() => true); const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext"); + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: false, + }); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: clipboardWrite }, @@ -1809,7 +1814,7 @@ describe("IssueDetail", () => { await Promise.resolve(); }); - expect(clipboardWrite).toHaveBeenCalledWith("# PAP-1: Copy me\n\nTask body"); + expect(clipboardWrite).not.toHaveBeenCalled(); expect(execCommand).toHaveBeenCalledWith("copy"); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Copied to clipboard", @@ -1828,6 +1833,12 @@ describe("IssueDetail", () => { // @ts-expect-error test cleanup for optional browser API delete document.execCommand; } + if (originalSecureContext) { + Object.defineProperty(window, "isSecureContext", originalSecureContext); + } else { + // @ts-expect-error test cleanup for optional browser API + delete window.isSecureContext; + } } });