Add reusable sandbox custom images (#8794)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A growing part of that work runs in sandboxed environments rather
than on the operator's local machine.
> - Today sandbox providers can start fresh workspaces and run probes,
but they do not have a shared contract for capturing and reusing
prepared sandbox state.
> - Operators need a way to set up tools, credentials, and project
dependencies once, then reuse that prepared image for later agent runs.
> - This pull request adds reusable sandbox custom images across the
provider contract, server runtime, and board UI.
> - It also keeps probes and sandbox copy flows aligned with
pre-authenticated/custom-image environments.
> - The benefit is faster, more reliable sandbox runs without repeatedly
rebuilding the same environment setup.

## Linked Issues or Issue Description

No public GitHub issue was found for this change. Inline feature request
follows.

### Problem or motivation

Sandboxed agents need reusable prepared runtime state so repeated runs
do not require manual setup every time. Operators often need system
packages, CLIs, SDKs, dependency caches, credentials, and project
tooling available before an agent can work productively.

### Proposed solution

Add a provider-level custom-image capability, server-side setup/capture
lifecycle, Daytona/fake provider support, and board UI controls for
creating, testing, selecting, and deleting custom images.

### Alternatives considered

Leaving this as provider-specific setup outside Paperclip would keep the
control plane blind to image state and would not give agents consistent
environment metadata. Re-running setup commands for every lease is
simpler, but slower and less reliable for interactive or credentialed
setup.

### Roadmap alignment

Checked `ROADMAP.md`; this aligns with the Cloud / Sandbox agents
roadmap area and does not duplicate any related public issue or PR found
by search.

Additional context:
- Subsystem affected: cross-cutting (`packages/db`, `packages/shared`,
`packages/plugins`, `server`, `ui`).
- Duplicate search: searched GitHub for `sandbox custom image` and
`sandbox template environment`; no related public issues or PRs were
found.

## What Changed

- Added custom-image shared types, validators, constants, API paths, and
database schema/migration.
- Added server services/routes for custom-image templates and setup
sessions, including runtime cleanup and provider metadata handling.
- Extended plugin/sandbox provider capabilities for interactive setup,
template capture, and template deletion.
- Implemented custom-image support in the fake sandbox provider and
Daytona provider.
- Updated environment runtime/config handling so active custom images
flow into leases, probes, and agent execution.
- Added board UI controls and API client support for custom-image setup,
capture, selection, status, and error states.
- Hardened sandbox copy/probe behavior for insecure clipboard contexts
and pre-authenticated sandbox images.
- Added targeted coverage across shared validators, DB schema, server
routes/services, provider plugins, adapter probes, and UI flows.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm vitest run
packages/adapters/claude-local/src/server/test.probe.test.ts
packages/adapters/claude-local/src/server/test.ts
packages/adapters/codex-local/src/server/test.remote.test.ts
packages/adapters/codex-local/src/server/test.ts`
- `pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `pnpm --filter @paperclipai/adapter-codex-local typecheck`
- `pnpm vitest run
packages/db/src/environment-custom-images-schema.test.ts
packages/shared/src/environment-custom-images.test.ts
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/environment-custom-images-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
packages/plugins/sandbox-providers/daytona/src/plugin.test.ts
ui/src/pages/CompanyEnvironments.test.tsx
ui/src/pages/CompanySettings.test.tsx`
- `pnpm -r typecheck`
- `pnpm build`
- `rm -rf packages/db/dist && pnpm test:run`
- Public-safety scan of the final diff found no internal Paperclip issue
links, private instance URLs, or real secret patterns.

## Risks

- Adds a database migration and new environment runtime tables, so
migration ordering and rollback need care.
- Provider implementations may differ in how reliably they can
capture/delete images; unsupported providers surface capability-gated UI
states.
- Custom-image state can contain operator-prepared tooling and
credentials inside the provider image, so providers must enforce their
own access controls and cleanup semantics.
- Broad surface area across shared contracts, server runtime, plugins,
adapters, and UI means CI and Greptile review should be watched closely.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 (`gpt-5`) via Codex CLI with tool use and code execution.
Assisted with branch cleanup, conflict resolution, local verification,
and PR preparation. Earlier branch implementation work was assisted by
Paperclip-managed Claude/Codex agents.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-06-30 09:57:49 -07:00 committed by GitHub
parent b61c2852fb
commit 8d9f9fd240
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 6260 additions and 56 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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<typeof getTableConfig>[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<typeof getTableConfig>[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();
});
});

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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<EnvironmentCustomImageSetupSessionStatus>().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<EnvironmentCustomImageSetupConnectionSummary | null>(),
connectionSecretRef: text("connection_secret_ref"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
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,
),
}),
);

View File

@ -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<EnvironmentCustomImageTemplateStatus>().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<Record<string, unknown>>(),
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,
),
}),
);

View File

@ -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";

View File

@ -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: {

View File

@ -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";

View File

@ -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<string, FakeLeaseState>();
const setupSessions = new Map<string, FakeSetupState>();
const templates = new Map<string, FakeTemplateState>();
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<string, unknown>): 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<string, unknown> {
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<FakeSetupState["status"], "cancelled" | "timed_out" | "failed"> {
if (reason === "timed_out") return "timed_out";
if (reason === "failed") return "failed";
return "cancelled";
}
async function removeLease(providerLeaseId: string | null | undefined): Promise<void> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentCaptureTemplateResult> {
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<PluginEnvironmentCancelInteractiveSetupResult> {
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<PluginEnvironmentDeleteTemplateResult> {
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;

View File

@ -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: {

View File

@ -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<string, unknown>];
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<string, unknown>];
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<string, unknown>];
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();

View File

@ -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<DaytonaSshAccess>;
_experimental_createSnapshot?: (name: string, timeout?: number) => Promise<void>;
};
type DaytonaSnapshotService = {
get?: (name: string) => Promise<unknown>;
delete?: (snapshot: unknown) => Promise<void>;
};
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<string, string> {
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<PluginEnvironmentStartInteractiveSetupParams, "sourceTemplateRef" | "sourceTemplateKind">,
): 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<Pick<PluginEnvironmentInteractiveSetupSession, "connectionSummary" | "connectionPayload">> {
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<Sandbox> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentCaptureTemplateResult> {
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<PluginEnvironmentCancelInteractiveSetupResult> {
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<PluginEnvironmentDeleteTemplateResult> {
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<PluginEnvironmentExecuteResult> {

View File

@ -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"]

View File

@ -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",
},
});

View File

@ -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<PluginEnvironmentExecuteResult>;
/** Called to start an interactive setup sandbox and return redacted connection metadata. */
onEnvironmentStartInteractiveSetup?(
params: PluginEnvironmentStartInteractiveSetupParams,
): Promise<PluginEnvironmentInteractiveSetupSession>;
/** Called to read setup status and, when authorized, a one-time connection payload. */
onEnvironmentGetInteractiveSetup?(
params: PluginEnvironmentGetInteractiveSetupParams,
): Promise<PluginEnvironmentInteractiveSetupSession>;
/** Called to capture a reusable provider template from a live setup sandbox. */
onEnvironmentCaptureTemplate?(
params: PluginEnvironmentCaptureTemplateParams,
): Promise<PluginEnvironmentCaptureTemplateResult>;
/** Called to cancel and clean up a setup sandbox without promoting a template. */
onEnvironmentCancelInteractiveSetup?(
params: PluginEnvironmentCancelInteractiveSetupParams,
): Promise<PluginEnvironmentCancelInteractiveSetupResult>;
/** Called for optional best-effort cleanup of a captured provider template. */
onEnvironmentDeleteTemplate?(
params: PluginEnvironmentDeleteTemplateParams,
): Promise<PluginEnvironmentDeleteTemplateResult>;
}
// ---------------------------------------------------------------------------

View File

@ -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,

View File

@ -647,6 +647,107 @@ export interface PluginEnvironmentExecuteResult {
metadata?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface PluginEnvironmentInteractiveSetupConnectionPayload {
type: PluginEnvironmentInteractiveSetupConnectionType;
command?: string | null;
token?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown>;
}
export interface PluginEnvironmentInteractiveSetupSession {
providerLeaseId: string | null;
status: PluginEnvironmentInteractiveSetupStatus;
connectionSummary: PluginEnvironmentInteractiveSetupConnectionSummary | null;
connectionPayload?: PluginEnvironmentInteractiveSetupConnectionPayload | null;
expiresAt?: string | null;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
includeConnectionPayload?: boolean;
connectionExpiresInMinutes?: number | null;
}
export interface PluginEnvironmentCaptureTemplateParams extends PluginEnvironmentDriverBaseParams {
providerLeaseId: string | null;
setupMetadata?: Record<string, unknown>;
sourceTemplateRef?: string | null;
previousTemplateRef?: string | null;
templateLabel?: string | null;
timeoutMs?: number | null;
}
export interface PluginEnvironmentCaptureTemplateResult {
templateRef: string;
templateKind: PluginEnvironmentTemplateRefKind;
metadata?: Record<string, unknown>;
}
export interface PluginEnvironmentCancelInteractiveSetupParams extends PluginEnvironmentDriverBaseParams {
providerLeaseId: string | null;
setupMetadata?: Record<string, unknown>;
reason?: string | null;
}
export interface PluginEnvironmentCancelInteractiveSetupResult {
status: Extract<PluginEnvironmentInteractiveSetupStatus, "cancelled" | "timed_out" | "failed" | "missing">;
metadata?: Record<string, unknown>;
}
export interface PluginEnvironmentDeleteTemplateParams extends PluginEnvironmentDriverBaseParams {
templateRef: string;
templateKind?: PluginEnvironmentTemplateRefKind;
metadata?: Record<string, unknown>;
reason?: string | null;
}
export interface PluginEnvironmentDeleteTemplateResult {
deleted: boolean;
metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// 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;
// ---------------------------------------------------------------------------

View File

@ -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<void>;
onRealizeWorkspace?: (params: PluginEnvironmentRealizeWorkspaceParams) => Promise<PluginEnvironmentRealizeWorkspaceResult>;
onExecute?: (params: PluginEnvironmentExecuteParams) => Promise<PluginEnvironmentExecuteResult>;
onStartInteractiveSetup?: (params: PluginEnvironmentStartInteractiveSetupParams) => Promise<PluginEnvironmentInteractiveSetupSession>;
onGetInteractiveSetup?: (params: PluginEnvironmentGetInteractiveSetupParams) => Promise<PluginEnvironmentInteractiveSetupSession>;
onCaptureTemplate?: (params: PluginEnvironmentCaptureTemplateParams) => Promise<PluginEnvironmentCaptureTemplateResult>;
onCancelInteractiveSetup?: (params: PluginEnvironmentCancelInteractiveSetupParams) => Promise<PluginEnvironmentCancelInteractiveSetupResult>;
onDeleteTemplate?: (params: PluginEnvironmentDeleteTemplateParams) => Promise<PluginEnvironmentDeleteTemplateResult>;
};
}
@ -192,6 +211,16 @@ export interface EnvironmentTestHarness extends TestHarness {
realizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams): Promise<PluginEnvironmentRealizeWorkspaceResult>;
/** Invoke the environment driver's execute hook. */
execute(params: PluginEnvironmentExecuteParams): Promise<PluginEnvironmentExecuteResult>;
/** Invoke the environment driver's interactive setup start hook. */
startInteractiveSetup(params: PluginEnvironmentStartInteractiveSetupParams): Promise<PluginEnvironmentInteractiveSetupSession>;
/** Invoke the environment driver's interactive setup status hook. */
getInteractiveSetup(params: PluginEnvironmentGetInteractiveSetupParams): Promise<PluginEnvironmentInteractiveSetupSession>;
/** Invoke the environment driver's template capture hook. */
captureTemplate(params: PluginEnvironmentCaptureTemplateParams): Promise<PluginEnvironmentCaptureTemplateResult>;
/** Invoke the environment driver's interactive setup cancel hook. */
cancelInteractiveSetup(params: PluginEnvironmentCancelInteractiveSetupParams): Promise<PluginEnvironmentCancelInteractiveSetupResult>;
/** Invoke the environment driver's optional template delete hook. */
deleteTemplate(params: PluginEnvironmentDeleteTemplateParams): Promise<PluginEnvironmentDeleteTemplateResult>;
}
// ---------------------------------------------------------------------------
@ -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;

View File

@ -59,6 +59,7 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginEnvironmentTemplateConfigBinding,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectDeclaration,

View File

@ -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
// -----------------------------------------------------------------------

View File

@ -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`,

View File

@ -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];

View File

@ -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,
});
});
});

View File

@ -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<string, unknown> {
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<T>(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<string, unknown> = {};
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<string, unknown> | 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<string, unknown> | null;
metadata?: Record<string, unknown> | 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),
};
}

View File

@ -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",

View File

@ -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";

View File

@ -550,6 +550,7 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginEnvironmentTemplateConfigBinding,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,

View File

@ -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;
}

View File

@ -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<typeof environmentCustomImageSetupConnectionSummarySchema>;
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<typeof environmentCustomImageTemplateSchema>;
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<typeof environmentCustomImageSetupSessionSchema>;
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<typeof startEnvironmentCustomImageSetupSessionSchema>;
export const finishEnvironmentCustomImageSetupSessionSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
}).strict();
export type FinishEnvironmentCustomImageSetupSession =
z.infer<typeof finishEnvironmentCustomImageSetupSessionSchema>;
export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
reason: z.string().min(1).max(1000).optional(),
}).strict();
export type CancelEnvironmentCustomImageSetupSession =
z.infer<typeof cancelEnvironmentCustomImageSetupSessionSchema>;

View File

@ -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,

View File

@ -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", () => {

View File

@ -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<string>();
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,
});

View File

@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as typeof req & { actor: Record<string, unknown> }).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<string, unknown> = {}) {
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");
});
});

View File

@ -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<string, unknown>) => {
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<void>) | null = null;
let db!: ReturnType<typeof createDb>;
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,
),
);
});
});

View File

@ -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<string, unknown> = {}) {
@ -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();

View File

@ -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",

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -38,6 +38,7 @@ import {
feedbackService,
backfillPrincipalAccessCompatibility,
bootstrapExecutionPolicyFromEnv,
environmentCustomImageService,
heartbeatService,
instanceSettingsService,
reconcileCloudUpstreamRunsOnStartup,
@ -775,6 +776,7 @@ export async function startServer(): Promise<StartedServer> {
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<StartedServer> {
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<StartedServer> {
.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.

View File

@ -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 {

View File

@ -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<typeof getActorInfo>;
companyId: string;
action: string;
entityId: string;
details: Record<string, unknown>;
}) {
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<string> {
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<string, unknown> | null;
connectionSecretRef: string | null;
metadata?: Record<string, unknown> | 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<string, unknown> | 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);

View File

@ -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<z.ZodRawShape> | 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({

View File

@ -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<Set<string>> {
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<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
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<ParsedEnvironmentConfig> {
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<string, unknown>,
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<string, unknown>,
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,
};
}

View File

@ -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<string, unknown> {
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<string, unknown> | 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> },
): string {
let normalized = config as Record<string, unknown>;
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<EnvironmentCustomImageTemplate, "templateKind" | "templateRef" | "metadata">,
): SandboxEnvironmentConfig {
if (!template.templateRef) return config;
const next = { ...(config as Record<string, unknown>) };
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<SandboxEnvironmentConfig> {
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);
}

View File

@ -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<string, unknown>).label);
return {
type: readConnectionType(summary.type),
username: readString(summary.username),
hostRedacted: true,
portRedacted: true,
...(label ? { label } : {}),
};
}
function normalizeProviderMetadata(metadata: Record<string, unknown> | null | undefined): Record<string, unknown> | 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<typeof templateConfigBindingFromDriver>,
templateKind: EnvironmentCustomImageTemplateKind,
): {
sourceTemplateRef: string | null;
sourceTemplateKind: EnvironmentCustomImageTemplateKind | null;
} {
const record = config as Record<string, unknown>;
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<EnvironmentCustomImageTemplate | null> {
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<EnvironmentCustomImageTemplate | null> {
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<EnvironmentCustomImageSetupSession | null> {
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<EnvironmentCustomImageSetupSession | null> {
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<EnvironmentCustomImageSetupSession | null> {
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<Environment> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentCaptureTemplateResult> {
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<PluginEnvironmentCancelInteractiveSetupResult> {
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<EnvironmentCustomImageSetupSession> {
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<string, unknown> | null;
}): Promise<EnvironmentCustomImageSetupSession> {
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<EnvironmentCustomImageSetupSession> {
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<EnvironmentCustomImageOverview> => {
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<EnvironmentCustomImageTemplate | null> => 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<EnvironmentCustomImageSetupSessionResult> => {
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<EnvironmentCustomImageSetupSessionResult> => {
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<string, unknown>;
now?: Date;
}): Promise<EnvironmentCustomImageSetupSessionResult & { template: EnvironmentCustomImageTemplate }> => {
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<EnvironmentCustomImageSetupSession> => {
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<EnvironmentCustomImageTemplate> => {
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<EnvironmentCustomImageSetupCleanupResult> => {
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 };
},
};
}

View File

@ -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<ExecutionWorkspace, "id" | "mode"> | 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<EnvironmentRuntimeLeaseRecord> {
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 {

View File

@ -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";

View File

@ -132,6 +132,11 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"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"],
};
/**

View File

@ -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<PluginEnvironmentStartInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentGetInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
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<PluginEnvironmentCaptureTemplateParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentCaptureTemplateResult> {
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<PluginEnvironmentCancelInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
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<PluginEnvironmentDeleteTemplateParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentDeleteTemplateResult> {
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: {

View File

@ -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<string, unknown> | 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<Environment[]>(`/companies/${companyId}/environments`),
capabilities: (companyId: string) =>
@ -29,4 +67,50 @@ export const environmentsApi = {
config?: Record<string, unknown>;
metadata?: Record<string, unknown> | null;
}) => api.post<EnvironmentProbeResult>(`/companies/${companyId}/environments/probe-config`, body),
customImageTemplate: (environmentId: string, companyId: string) =>
api.get<EnvironmentCustomImageOverview>(
`/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}`,
),
startCustomImageSetupSession: (
environmentId: string,
companyId: string,
body: StartEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSessionResult>(
`/environments/${environmentId}/custom-image-setup-sessions?companyId=${encodeURIComponent(companyId)}`,
body,
),
customImageSetupSession: (sessionId: string) =>
api.get<EnvironmentCustomImageSetupSessionResult>(
`/environment-custom-image-setup-sessions/${sessionId}`,
),
finishCustomImageSetupSession: (
sessionId: string,
body: FinishEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageFinishResult>(
`/environment-custom-image-setup-sessions/${sessionId}/finish`,
body,
),
cancelCustomImageSetupSession: (
sessionId: string,
body: CancelEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSession>(
`/environment-custom-image-setup-sessions/${sessionId}/cancel`,
body,
),
rollbackCustomImageTemplate: (environmentId: string, companyId: string) =>
api.post<EnvironmentCustomImageRollbackResult>(
`/environments/${environmentId}/custom-image-template/rollback?companyId=${encodeURIComponent(companyId)}`,
{},
),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { deleteProviderTemplate?: boolean } = {},
) =>
api.delete<EnvironmentCustomImageTemplate>(
`/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
),
};

View File

@ -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;
}
}
});

View File

@ -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", () => {

View File

@ -40,7 +40,7 @@ function makeArtifact(overrides: Partial<CompanyArtifact> = {}): 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,
};

View File

@ -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<string, string>,
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();
});
});

View File

@ -1,5 +1,10 @@
export async function copyTextToClipboard(text: string): Promise<void> {
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<void> {
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 });
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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",
},

View File

@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<typeof createRoot> | null;
let probeResolvers: Map<string, () => 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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
@ -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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
@ -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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
@ -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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
@ -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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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<typeof createSession> | null = createSession();
let latestSession: ReturnType<typeof createSession> | 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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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" },
);
});
});

View File

@ -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, unknown>): string | null
return null;
}
const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = new Set<EnvironmentCustomImageSetupSession["status"]>([
"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 (
<div className="mt-3 border-t border-border/60 pt-3 text-xs" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="font-medium text-foreground">{state.label}</div>
<div className="mt-1 text-muted-foreground">{state.reason}</div>
</div>
);
}
if (overviewQuery.isLoading) {
return (
<div className="mt-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
Loading template setup...
</div>
);
}
if (overviewQuery.isError) {
return (
<div className="mt-3 border-t border-border/60 pt-3 text-xs text-destructive">
{overviewQuery.error instanceof Error ? overviewQuery.error.message : "Template setup could not be loaded."}
</div>
);
}
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 (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-xs font-medium">{sessionStatusCopy(session.status)}</div>
<div className="text-xs text-muted-foreground">
{providerDisplayName}{sessionExpiresAt ? ` · expires ${sessionExpiresAt}` : ""}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => finishSetupMutation.mutate(session.id)}
disabled={isMutating || session.status !== "waiting_for_user"}
>
<Check className="mr-1.5 h-3.5 w-3.5" />
Finished
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => cancelSetupMutation.mutate(session.id)}
disabled={isMutating || isCapturing}
>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
</div>
</div>
{session.status === "waiting_for_user" && connectionCommand ? (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md bg-muted/30 px-3 py-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">
{connectionCommand}
</code>
</div>
) : null}
{session.status === "waiting_for_user" && !connectionCommand ? (
<div className="mt-2 text-xs text-muted-foreground">
Connection details are not available yet.
</div>
) : null}
{session.failureReason ? (
<div className="mt-2 text-xs text-destructive">{session.failureReason}</div>
) : null}
</div>
);
}
if (activeTemplate) {
return (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-xs font-medium">Active template</div>
<div className="text-xs text-muted-foreground">
{providerDisplayName} · {activeTemplate.templateKind}
{capturedAt ? ` · captured ${capturedAt}` : ""}
{lastUsedAt ? ` · last used ${lastUsedAt}` : ""}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => startSetupMutation.mutate({ templateId: activeTemplate.id })}
disabled={isMutating}
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => rollbackTemplateMutation.mutate()}
disabled={isMutating}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Rollback
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => disableTemplateMutation.mutate()}
disabled={isMutating}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Disable
</Button>
</div>
</div>
</div>
);
}
return (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-medium">Not configured</div>
<div className="text-xs text-muted-foreground">
{latestSession
? sessionStatusCopy(latestSession.status)
: `Capture a custom ${providerDisplayName} image with your tools already logged in.`}
</div>
{latestSession?.failureReason ? (
<div className="text-xs text-destructive">{latestSession.failureReason}</div>
) : null}
</div>
<Button
size="sm"
variant="outline"
onClick={() => startSetupMutation.mutate({ templateId: null })}
disabled={isMutating}
>
<Play className="mr-1.5 h-3.5 w-3.5" />
Configure image
</Button>
</div>
</div>
);
}
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 (
<div
key={environment.id}
@ -547,12 +983,8 @@ export function CompanyEnvironments() {
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
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<string, unknown>);
return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
return `${sandboxProviderDisplayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</div>
) : (
@ -693,7 +1125,7 @@ export function CompanyEnvironments() {
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH login user.">
<Field label="Username" hint="SSH username.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
@ -804,6 +1236,24 @@ export function CompanyEnvironments() {
</div>
) : null}
{editingEnvironment &&
editingEnvironment.driver === "sandbox" &&
environmentForm.driver === "sandbox" ? (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="text-sm font-medium">Custom image</div>
<div className="text-xs text-muted-foreground">
Start a setup sandbox, SSH in to customize the instance, then capture the
running machine as a reusable image for future runs.
</div>
<EnvironmentImageTemplatePanel
companyId={selectedCompanyId}
environment={editingEnvironment}
providerCapability={editingSandboxCapability}
providerDisplayName={editingSandboxDisplayName}
/>
</div>
) : null}
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."

View File

@ -1776,6 +1776,11 @@ describe("IssueDetail", () => {
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;
}
}
});