Scope environment custom images to instance environments (#8850)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments are now managed as instance-level runtime resources rather than per-company rows > - The custom environment image setup tables were introduced with their own `company_id` columns and route query parameters > - That split made one saved environment image state depend on an extra company context even though the environment itself is the durable owner > - It also made saved-environment probes harder because applying the active custom image template could require a company context when no secret-backed config needed one > - This pull request scopes custom image templates and setup sessions directly to the saved environment > - The benefit is that reusable environment images follow the same instance-scoped model as environments while secret resolution still uses company context only when secrets require it ## Linked Issues or Issue Description No matching public GitHub issue was found. Bug report: ### What happened? saved environment custom-image routes and persistence required a `companyId` even though environments are instance-scoped, and saved sandbox probes did not opt into active custom-image template application unless a company context was present. ### Expected behavior custom-image templates and setup sessions should be owned by the saved environment, and saved sandbox probes should apply the active template while still requiring a company context only for secret-backed runtime config. ### Steps to reproduce 1. Configure an instance-scoped sandbox environment with custom-image setup support. 2. Start or inspect a custom-image session or template for that saved environment. 3. Probe the saved environment without a custom-image-specific `companyId` query parameter. ### Paperclip version or commit current `master` after the environment custom-image template migration. ### Deployment mode Local dev (pnpm dev) or authenticated local Paperclip instance. ### Installation method Built from source (pnpm dev / pnpm build). ### Agent adapter(s) involved Not adapter-specific (core bug). ### Database mode Embedded PGlite/Postgres dev database. ### Access context Board human operator. ### Privacy checklist No logs, secrets, tokens, private URLs, or local machine paths are included. Duplicate search performed: - `gh search prs "environment custom image companyId repo:paperclipai/paperclip" --state open --limit 20` - `gh search prs "custom image environment scoped repo:paperclipai/paperclip" --state open --limit 20` - `gh search issues "environment custom image repo:paperclipai/paperclip" --state open --limit 20` The returned results were unrelated adapter, Docker, auth, or stale-workspace items. ## What Changed - Removed redundant `company_id` columns from environment custom-image templates and setup sessions. - Added migration `0127_environment_custom_images_instance_scoped` to collapse duplicate active rows per environment before dropping the old company-scoped indexes/columns. - Updated custom-image services, route handlers, shared validators, and UI API/query keys to use environment-scoped custom-image state. - Kept runtime secret resolution company-aware only when secret refs or bindings require a company context. - Made saved sandbox environment probes opt into active custom-image template application. - Updated DB, shared, server, and UI tests for the new environment-scoped contract. ## Verification - `pnpm --filter @paperclipai/db run check:migrations` - `pnpm exec vitest run packages/db/src/environment-custom-images-schema.test.ts packages/shared/src/environment-custom-images.test.ts server/src/__tests__/environment-custom-image-routes.test.ts server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-routes.test.ts ui/src/pages/CompanyEnvironments.test.tsx` - `pnpm -r typecheck` - `pnpm test:run` before rebasing onto latest `master`; after the rebase only the migration number changed, and the migration check plus focused suite, typecheck, and build were rerun. - `pnpm build` ## Risks - Migration safety: the migration supersedes duplicate active templates per environment and fails duplicate active setup sessions before adding environment-only unique indexes. Operators with duplicate historical active rows should review which active template is kept. - Behavior shift: plugin custom-image setup calls now receive `companyId: "instance"` when no secret binding determines a concrete company context. - Secret-backed configs still require an explicit or uniquely inferable company context; environments with secret bindings spread across multiple companies continue to fail fast. > 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 Codex via the `codex_local` adapter, GPT-5-based coding model with tool-enabled repository inspection, editing, testing, git, and GitHub CLI access. Exact context-window metadata was not exposed by the runtime. ## 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:
parent
2eba718bef
commit
b4815bf964
|
|
@ -14,33 +14,33 @@ function indexWhere(table: Parameters<typeof getTableConfig>[0], indexName: stri
|
|||
}
|
||||
|
||||
describe("environment customImage schema", () => {
|
||||
it("scopes template lookup and active uniqueness by company and environment", () => {
|
||||
it("scopes template lookup and active uniqueness by environment", () => {
|
||||
expect(indexColumns(
|
||||
environmentCustomImageTemplates,
|
||||
"environment_custom_image_templates_company_environment_status_idx",
|
||||
)).toEqual(["company_id", "environment_id", "status"]);
|
||||
"environment_custom_image_templates_environment_status_idx",
|
||||
)).toEqual(["environment_id", "status"]);
|
||||
expect(indexColumns(
|
||||
environmentCustomImageTemplates,
|
||||
"environment_custom_image_templates_company_environment_active_uq",
|
||||
)).toEqual(["company_id", "environment_id"]);
|
||||
"environment_custom_image_templates_environment_active_uq",
|
||||
)).toEqual(["environment_id"]);
|
||||
expect(indexWhere(
|
||||
environmentCustomImageTemplates,
|
||||
"environment_custom_image_templates_company_environment_active_uq",
|
||||
"environment_custom_image_templates_environment_active_uq",
|
||||
)).toBeDefined();
|
||||
});
|
||||
|
||||
it("scopes setup-session lookup and active uniqueness by company and environment", () => {
|
||||
it("scopes setup-session lookup and active uniqueness by environment", () => {
|
||||
expect(indexColumns(
|
||||
environmentCustomImageSetupSessions,
|
||||
"environment_custom_image_setup_sessions_company_environment_status_idx",
|
||||
)).toEqual(["company_id", "environment_id", "status"]);
|
||||
"environment_custom_image_setup_sessions_environment_status_idx",
|
||||
)).toEqual(["environment_id", "status"]);
|
||||
expect(indexColumns(
|
||||
environmentCustomImageSetupSessions,
|
||||
"environment_custom_image_setup_sessions_company_environment_active_uq",
|
||||
)).toEqual(["company_id", "environment_id"]);
|
||||
"environment_custom_image_setup_sessions_environment_active_uq",
|
||||
)).toEqual(["environment_id"]);
|
||||
expect(indexWhere(
|
||||
environmentCustomImageSetupSessions,
|
||||
"environment_custom_image_setup_sessions_company_environment_active_uq",
|
||||
"environment_custom_image_setup_sessions_environment_active_uq",
|
||||
)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "environment_custom_image_templates"
|
||||
WHERE "status" = 'active'
|
||||
GROUP BY "environment_id"
|
||||
HAVING count(*) > 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot migrate environment custom image templates to environment scope while multiple active templates exist for the same environment. Revoke or supersede the extra active templates before retrying.';
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "environment_custom_image_setup_sessions"
|
||||
WHERE "status" IN ('starting', 'waiting_for_user', 'capturing')
|
||||
GROUP BY "environment_id"
|
||||
HAVING count(*) > 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot migrate environment custom image setup sessions to environment scope while multiple active sessions exist for the same environment. Finish or cancel the extra sessions before retrying.';
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
UPDATE "environment_custom_image_templates"
|
||||
SET
|
||||
"metadata" = jsonb_set(
|
||||
COALESCE("metadata", '{}'::jsonb),
|
||||
'{setupRpcCompanyId}',
|
||||
to_jsonb("company_id"::text),
|
||||
true
|
||||
),
|
||||
"updated_at" = now()
|
||||
WHERE "company_id" IS NOT NULL
|
||||
AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = '';
|
||||
--> statement-breakpoint
|
||||
UPDATE "environment_custom_image_setup_sessions"
|
||||
SET
|
||||
"metadata" = jsonb_set(
|
||||
COALESCE("metadata", '{}'::jsonb),
|
||||
'{setupRpcCompanyId}',
|
||||
to_jsonb("company_id"::text),
|
||||
true
|
||||
),
|
||||
"updated_at" = now()
|
||||
WHERE "company_id" IS NOT NULL
|
||||
AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = '';
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_provider_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_active_uq";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_last_used_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_active_uq";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_template_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_promoted_template_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_expires_idx";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_templates"
|
||||
DROP CONSTRAINT IF EXISTS "environment_custom_image_templates_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_setup_sessions"
|
||||
DROP CONSTRAINT IF EXISTS "environment_custom_image_setup_sessions_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_templates" DROP COLUMN IF EXISTS "company_id";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_setup_sessions" DROP COLUMN IF EXISTS "company_id";
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_templates_environment_status_idx"
|
||||
ON "environment_custom_image_templates" USING btree ("environment_id", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_templates_environment_provider_status_idx"
|
||||
ON "environment_custom_image_templates" USING btree ("environment_id", "provider", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "environment_custom_image_templates_environment_active_uq"
|
||||
ON "environment_custom_image_templates" USING btree ("environment_id")
|
||||
WHERE "status" = 'active';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_templates_last_used_idx"
|
||||
ON "environment_custom_image_templates" USING btree ("last_used_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_setup_sessions_environment_status_idx"
|
||||
ON "environment_custom_image_setup_sessions" USING btree ("environment_id", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "environment_custom_image_setup_sessions_environment_active_uq"
|
||||
ON "environment_custom_image_setup_sessions" USING btree ("environment_id")
|
||||
WHERE "status" IN ('starting', 'waiting_for_user', 'capturing');
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_setup_sessions_template_idx"
|
||||
ON "environment_custom_image_setup_sessions" USING btree ("template_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_setup_sessions_promoted_template_idx"
|
||||
ON "environment_custom_image_setup_sessions" USING btree ("promoted_template_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "environment_custom_image_setup_sessions_expires_idx"
|
||||
ON "environment_custom_image_setup_sessions" USING btree ("expires_at");
|
||||
|
|
@ -890,6 +890,13 @@
|
|||
"when": 1782526400000,
|
||||
"tag": "0126_issue_comment_derived_attribution",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 127,
|
||||
"version": "7",
|
||||
"when": 1782526500000,
|
||||
"tag": "0127_environment_custom_images_instance_scoped",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
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";
|
||||
|
|
@ -14,7 +13,6 @@ 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")
|
||||
|
|
@ -36,26 +34,17 @@ export const environmentCustomImageSetupSessions = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyEnvironmentStatusIdx: index("environment_custom_image_setup_sessions_company_environment_status_idx").on(
|
||||
table.companyId,
|
||||
environmentStatusIdx: index("environment_custom_image_setup_sessions_environment_status_idx").on(
|
||||
table.environmentId,
|
||||
table.status,
|
||||
),
|
||||
companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_company_environment_active_uq")
|
||||
.on(table.companyId, table.environmentId)
|
||||
environmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_environment_active_uq")
|
||||
.on(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,
|
||||
),
|
||||
templateIdx: index("environment_custom_image_setup_sessions_template_idx").on(table.templateId),
|
||||
promotedTemplateIdx: index("environment_custom_image_setup_sessions_promoted_template_idx")
|
||||
.on(table.promotedTemplateId),
|
||||
expiresIdx: index("environment_custom_image_setup_sessions_expires_idx").on(table.expiresAt),
|
||||
providerLeaseIdx: index("environment_custom_image_setup_sessions_provider_lease_idx").on(
|
||||
table.provider,
|
||||
table.providerLeaseId,
|
||||
|
|
|
|||
|
|
@ -11,14 +11,12 @@ import {
|
|||
} 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"),
|
||||
|
|
@ -37,23 +35,19 @@ export const environmentCustomImageTemplates = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyEnvironmentStatusIdx: index("environment_custom_image_templates_company_environment_status_idx").on(
|
||||
table.companyId,
|
||||
environmentStatusIdx: index("environment_custom_image_templates_environment_status_idx").on(
|
||||
table.environmentId,
|
||||
table.status,
|
||||
),
|
||||
companyProviderStatusIdx: index("environment_custom_image_templates_company_provider_status_idx").on(
|
||||
table.companyId,
|
||||
environmentProviderStatusIdx: index("environment_custom_image_templates_environment_provider_status_idx").on(
|
||||
table.environmentId,
|
||||
table.provider,
|
||||
table.status,
|
||||
),
|
||||
companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_templates_company_environment_active_uq")
|
||||
.on(table.companyId, table.environmentId)
|
||||
environmentActiveUq: uniqueIndex("environment_custom_image_templates_environment_active_uq")
|
||||
.on(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,
|
||||
),
|
||||
lastUsedIdx: index("environment_custom_image_templates_last_used_idx").on(table.lastUsedAt),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,16 +11,14 @@ import {
|
|||
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", () => {
|
||||
it("requires environment scope on templates and setup sessions", () => {
|
||||
const template = environmentCustomImageTemplateSchema.parse({
|
||||
id: templateId,
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
|
|
@ -38,12 +36,10 @@ describe("environment customImage validators", () => {
|
|||
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,
|
||||
|
|
@ -69,7 +65,6 @@ describe("environment customImage validators", () => {
|
|||
updatedAt: "2026-06-25T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(session.companyId).toBe(companyId);
|
||||
expect(session.environmentId).toBe(environmentId);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ export type EnvironmentCustomImageSetupConnectionSummary =
|
|||
|
||||
export const environmentCustomImageTemplateSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
environmentId: z.string().uuid(),
|
||||
provider: providerKeySchema,
|
||||
templateKind: environmentCustomImageTemplateKindSchema,
|
||||
|
|
@ -54,7 +53,6 @@ export type EnvironmentCustomImageTemplate =
|
|||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -120,7 +120,6 @@ function createEnvironment(overrides: Record<string, unknown> = {}) {
|
|||
function createTemplate(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "template-1",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
|
|
@ -143,7 +142,6 @@ function createTemplate(overrides: Record<string, unknown> = {}) {
|
|||
function createSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "session-1",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
templateId: "template-1",
|
||||
promotedTemplateId: null,
|
||||
|
|
@ -282,13 +280,12 @@ describe("environment customImage setup routes", () => {
|
|||
|
||||
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")
|
||||
.post("/api/environments/env-1/custom-image-setup-sessions")
|
||||
.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,
|
||||
|
|
@ -320,7 +317,7 @@ describe("environment customImage setup routes", () => {
|
|||
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")
|
||||
.post("/api/environments/env-1/custom-image-setup-sessions")
|
||||
.send({});
|
||||
const status = await request(app)
|
||||
.get("/api/environment-custom-image-setup-sessions/session-1");
|
||||
|
|
@ -346,18 +343,17 @@ describe("environment customImage setup routes", () => {
|
|||
expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies single-company fallback when the board actor is not a member", async () => {
|
||||
it("does not require a company fallback to start setup", async () => {
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-2"]);
|
||||
|
||||
const res = await request(createApp(boardActor({
|
||||
companyIds: ["company-1"],
|
||||
isInstanceAdmin: false,
|
||||
})))
|
||||
const res = await request(createApp(boardActor()))
|
||||
.post("/api/environments/env-1/custom-image-setup-sessions")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockEnvironmentCustomImageService.startSetupSession).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockEnvironmentCustomImageService.startSetupSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
environmentId: "env-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("finishes and promotes a template while logging redacted template details", async () => {
|
||||
|
|
@ -395,22 +391,20 @@ describe("environment customImage setup routes", () => {
|
|||
expect(loggedActivityJson()).not.toContain("lease-secret");
|
||||
});
|
||||
|
||||
it("rolls back and disables active templates through company-scoped routes", async () => {
|
||||
it("rolls back and disables active templates through instance-scoped routes", async () => {
|
||||
const app = createApp(boardActor());
|
||||
const rollback = await request(app)
|
||||
.post("/api/environments/env-1/custom-image-template/rollback?companyId=company-1")
|
||||
.post("/api/environments/env-1/custom-image-template/rollback")
|
||||
.send({});
|
||||
const disable = await request(app)
|
||||
.delete("/api/environments/env-1/custom-image-template?companyId=company-1&deleteProviderTemplate=true");
|
||||
.delete("/api/environments/env-1/custom-image-template?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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,12 +157,10 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
|
||||
async function seed() {
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values([
|
||||
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)}`,
|
||||
|
|
@ -184,16 +182,15 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
manifestJson: pluginManifest(),
|
||||
status: "ready",
|
||||
});
|
||||
return { companyId, otherCompanyId, environmentId };
|
||||
return { companyId, environmentId };
|
||||
}
|
||||
|
||||
it("starts, refreshes, finishes, refreshes again, and rolls back setup sessions", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const { 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,
|
||||
|
|
@ -221,25 +218,69 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
});
|
||||
|
||||
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 });
|
||||
const rollback = await service.rollbackTemplate({ 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 () => {
|
||||
it("reuses the setup provider company context across lifecycle calls", 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" },
|
||||
secretContextCompanyId: companyId,
|
||||
});
|
||||
expect(started.session.metadata).toMatchObject({
|
||||
setupRpcCompanyId: companyId,
|
||||
});
|
||||
|
||||
await service.refreshSetupSession({
|
||||
sessionId: started.session.id,
|
||||
includeConnectionPayload: true,
|
||||
});
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
const lifecycleCalls = workerManager.call.mock.calls
|
||||
.filter(([, method]) => [
|
||||
"environmentStartInteractiveSetup",
|
||||
"environmentGetInteractiveSetup",
|
||||
"environmentCaptureTemplate",
|
||||
"environmentCancelInteractiveSetup",
|
||||
].includes(method))
|
||||
.map(([, method, params]) => ({
|
||||
method,
|
||||
companyId: (params as Record<string, unknown>).companyId,
|
||||
}));
|
||||
|
||||
expect(lifecycleCalls).toEqual([
|
||||
{ method: "environmentStartInteractiveSetup", companyId },
|
||||
{ method: "environmentGetInteractiveSetup", companyId },
|
||||
{ method: "environmentCaptureTemplate", companyId },
|
||||
{ method: "environmentCancelInteractiveSetup", companyId },
|
||||
]);
|
||||
expect(promoted.session.metadata).toMatchObject({
|
||||
setupRpcCompanyId: companyId,
|
||||
});
|
||||
expect(promoted.template.metadata).toMatchObject({
|
||||
setupRpcCompanyId: companyId,
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes the active template before deleting the provider template", async () => {
|
||||
const { environmentId } = await seed();
|
||||
const workerManager = createWorkerManager();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const started = await service.startSetupSession({
|
||||
environmentId,
|
||||
actor: { userId: "user-1" },
|
||||
});
|
||||
|
|
@ -258,7 +299,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
});
|
||||
|
||||
const disabled = await service.disableTemplate({
|
||||
companyId,
|
||||
environmentId,
|
||||
deleteProviderTemplate: true,
|
||||
});
|
||||
|
|
@ -276,13 +316,53 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("cancels and times out setup sessions without changing the active template", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
it("does not send provider template refs to a different current provider", async () => {
|
||||
const { environmentId } = await seed();
|
||||
const workerManager = createWorkerManager();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const started = await service.startSetupSession({
|
||||
environmentId,
|
||||
actor: { userId: "user-1" },
|
||||
});
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
const deleteCallsBefore = workerManager.call.mock.calls
|
||||
.filter(([, method]) => method === "environmentDeleteTemplate")
|
||||
.length;
|
||||
|
||||
await db.update(environments)
|
||||
.set({
|
||||
config: {
|
||||
provider: "other-plugin",
|
||||
image: "other:base",
|
||||
reuseLease: false,
|
||||
},
|
||||
})
|
||||
.where(eq(environments.id, environmentId));
|
||||
|
||||
await expect(service.disableTemplate({
|
||||
environmentId,
|
||||
deleteProviderTemplate: true,
|
||||
})).rejects.toThrow("Environment customImage provider changed");
|
||||
|
||||
const deleteCallsAfter = workerManager.call.mock.calls
|
||||
.filter(([, method]) => method === "environmentDeleteTemplate")
|
||||
.length;
|
||||
const [templateRow] = await db
|
||||
.select({ status: environmentCustomImageTemplates.status })
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.id, promoted.template.id));
|
||||
|
||||
expect(deleteCallsAfter).toBe(deleteCallsBefore);
|
||||
expect(templateRow?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("cancels and times out setup sessions without changing the active template", async () => {
|
||||
const { environmentId } = await seed();
|
||||
const workerManager = createWorkerManager();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const started = await service.startSetupSession({
|
||||
companyId,
|
||||
environmentId,
|
||||
actor: { userId: "user-1" },
|
||||
});
|
||||
|
|
@ -291,10 +371,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
reason: "user_cancelled",
|
||||
});
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
expect(await service.getActiveTemplate({ companyId, environmentId })).toBeNull();
|
||||
expect(await service.getActiveTemplate({ environmentId })).toBeNull();
|
||||
|
||||
const expired = await service.startSetupSession({
|
||||
companyId,
|
||||
environmentId,
|
||||
actor: { userId: "user-1" },
|
||||
ttlSeconds: 60,
|
||||
|
|
@ -308,21 +387,32 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
expect(timedOut?.status).toBe("timed_out");
|
||||
});
|
||||
|
||||
it("rejects templates from another company or environment", async () => {
|
||||
const { companyId, otherCompanyId, environmentId } = await seed();
|
||||
it("rejects templates from another environment", async () => {
|
||||
const { environmentId } = await seed();
|
||||
const otherEnvironmentId = randomUUID();
|
||||
await db.insert(environments).values({
|
||||
id: otherEnvironmentId,
|
||||
name: `Other ${otherEnvironmentId.slice(0, 8)}`,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:base",
|
||||
reuseLease: false,
|
||||
},
|
||||
envVars: {},
|
||||
});
|
||||
const workerManager = createWorkerManager();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
const [otherTemplate] = await db.insert(environmentCustomImageTemplates).values({
|
||||
companyId: otherCompanyId,
|
||||
environmentId,
|
||||
environmentId: otherEnvironmentId,
|
||||
provider: "fake-plugin",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-other-company",
|
||||
templateRef: "snapshot-other-environment",
|
||||
status: "active",
|
||||
}).returning();
|
||||
|
||||
await expect(service.startSetupSession({
|
||||
companyId,
|
||||
environmentId,
|
||||
templateId: otherTemplate!.id,
|
||||
actor: { userId: "user-1" },
|
||||
|
|
@ -342,7 +432,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
expect(fallback.config).toMatchObject({ image: "fake:base" });
|
||||
|
||||
await db.insert(environmentCustomImageTemplates).values({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
templateKind: "snapshot",
|
||||
|
|
@ -380,7 +469,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
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",
|
||||
|
|
@ -414,7 +502,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const started = await service.startSetupSession({
|
||||
companyId,
|
||||
environmentId,
|
||||
actor: { userId: "user-1" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1346,6 +1346,7 @@ describe("environment routes", () => {
|
|||
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
||||
companyId: null,
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: false,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -1444,6 +1445,7 @@ describe("environment routes", () => {
|
|||
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
||||
companyId: null,
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: true,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -1460,6 +1462,48 @@ describe("environment routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("probes saved sandbox environments with the active custom image template without company context", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-sandbox",
|
||||
name: "Daytona Sandbox",
|
||||
driver: "sandbox" as const,
|
||||
config: {
|
||||
provider: "daytona",
|
||||
image: "ubuntu:24.04",
|
||||
reuseLease: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockProbeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: "Connected to Daytona sandbox.",
|
||||
details: {
|
||||
provider: "daytona",
|
||||
snapshot: "captured-template",
|
||||
},
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/environments/${environment.id}/probe`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.driver).toBe("sandbox");
|
||||
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
||||
companyId: null,
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes unsaved provider config without persisting secrets", async () => {
|
||||
mockProbeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -71,17 +71,6 @@ 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);
|
||||
|
|
@ -139,47 +128,6 @@ 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,
|
||||
|
|
@ -339,9 +287,7 @@ 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);
|
||||
|
|
@ -352,11 +298,10 @@ export function environmentRoutes(
|
|||
validate(startEnvironmentCustomImageSetupSessionSchema),
|
||||
async (req, res) => {
|
||||
assertCanAccessInstanceEnvironments(req);
|
||||
const companyId = await resolveCustomImageCompanyId(req);
|
||||
const environmentId = req.params.environmentId as string;
|
||||
const actor = getActorInfo(req);
|
||||
const result = await customImages.startSetupSession({
|
||||
companyId,
|
||||
environmentId: req.params.environmentId as string,
|
||||
environmentId,
|
||||
templateId: req.body.templateId ?? null,
|
||||
ttlSeconds: req.body.ttlSeconds ?? null,
|
||||
actor: {
|
||||
|
|
@ -364,9 +309,8 @@ export function environmentRoutes(
|
|||
agentId: actor.agentId,
|
||||
},
|
||||
});
|
||||
await logEnvironmentCustomImageActivity({
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
companyId,
|
||||
action: "environment.custom_image_setup.started",
|
||||
entityId: result.session.environmentId,
|
||||
details: setupSessionActivityDetails(result.session),
|
||||
|
|
@ -382,7 +326,6 @@ export function environmentRoutes(
|
|||
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,
|
||||
|
|
@ -400,15 +343,13 @@ export function environmentRoutes(
|
|||
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({
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
companyId: session.companyId,
|
||||
action: "environment.custom_image_setup.finished",
|
||||
entityId: result.session.environmentId,
|
||||
details: {
|
||||
|
|
@ -430,15 +371,13 @@ export function environmentRoutes(
|
|||
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({
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
companyId: session.companyId,
|
||||
action: "environment.custom_image_setup.cancelled",
|
||||
entityId: cancelled.environmentId,
|
||||
details: setupSessionActivityDetails(cancelled),
|
||||
|
|
@ -449,15 +388,12 @@ export function environmentRoutes(
|
|||
|
||||
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({
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
companyId,
|
||||
action: "environment.custom_image_template.rolled_back",
|
||||
entityId: req.params.environmentId as string,
|
||||
details: {
|
||||
|
|
@ -470,16 +406,13 @@ export function environmentRoutes(
|
|||
|
||||
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({
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
companyId,
|
||||
action: "environment.custom_image_template.disabled",
|
||||
entityId: req.params.environmentId as string,
|
||||
details: templateActivityDetails(template),
|
||||
|
|
@ -715,6 +648,7 @@ export function environmentRoutes(
|
|||
const probe = await probeEnvironment(db, environment, {
|
||||
companyId: companyIdForSecrets,
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
applyCustomImageTemplate: environment.driver === "sandbox",
|
||||
});
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
|||
|
||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
companyId: string | null,
|
||||
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
|
||||
context?: {
|
||||
issueId?: string | null;
|
||||
|
|
@ -576,6 +576,9 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
|||
}
|
||||
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
if (!companyId) {
|
||||
throw unprocessable("Runtime secret resolution requires a companyId context");
|
||||
}
|
||||
return {
|
||||
driver: "ssh",
|
||||
config: {
|
||||
|
|
@ -600,22 +603,31 @@ 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;
|
||||
let runtimeConfig = parsed.config;
|
||||
if (companyId) {
|
||||
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;
|
||||
} else {
|
||||
for (const path of collectSecretRefPaths(schema)) {
|
||||
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
|
||||
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
|
||||
throw unprocessable("Runtime secret resolution requires a companyId context");
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
driver: "sandbox",
|
||||
config: environmentId && (context?.issueId || context?.heartbeatRunId || context?.applyCustomImageTemplate)
|
||||
? await resolveActiveEnvironmentCustomImageTemplateForRuntime(db, {
|
||||
companyId,
|
||||
environmentId,
|
||||
baseConfig: parsed.config,
|
||||
runtimeConfig,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,6 @@ export function applyCustomImageTemplateToSandboxConfig(
|
|||
export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
environmentId: row.environmentId,
|
||||
provider: row.provider,
|
||||
templateKind: readEnvironmentCustomImageTemplateKind(row.templateKind),
|
||||
|
|
@ -136,7 +135,6 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ
|
|||
export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
baseConfig: SandboxEnvironmentConfig;
|
||||
runtimeConfig: SandboxEnvironmentConfig;
|
||||
|
|
@ -147,7 +145,6 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
|||
.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"),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
resolveSandboxProviderSecretRefPaths,
|
||||
stripSandboxProviderEnvelope,
|
||||
} from "./environment-config.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import {
|
||||
resolvePluginExecuteRpcTimeoutMs,
|
||||
resolvePluginSandboxProviderDriverByKey,
|
||||
|
|
@ -49,6 +50,8 @@ 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;
|
||||
const SETUP_RPC_COMPANY_ID_METADATA_KEY = "setupRpcCompanyId";
|
||||
const SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY = "sourceEnvironmentConfigFingerprint";
|
||||
|
||||
type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect;
|
||||
|
||||
|
|
@ -72,7 +75,6 @@ export interface EnvironmentCustomImageSetupCleanupResult {
|
|||
function toSession(row: SetupSessionRow): EnvironmentCustomImageSetupSession {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
environmentId: row.environmentId,
|
||||
templateId: row.templateId ?? null,
|
||||
promotedTemplateId: row.promotedTemplateId ?? null,
|
||||
|
|
@ -130,6 +132,42 @@ function normalizeProviderMetadata(metadata: Record<string, unknown> | null | un
|
|||
return redactEnvironmentCustomImageValue(metadata);
|
||||
}
|
||||
|
||||
function metadataRecord(metadata: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
|
||||
}
|
||||
|
||||
function normalizeSetupRpcCompanyId(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readSetupRpcCompanyId(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||
return normalizeSetupRpcCompanyId(metadataRecord(metadata)[SETUP_RPC_COMPANY_ID_METADATA_KEY]);
|
||||
}
|
||||
|
||||
function persistedSetupMetadata(metadata: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const record = metadataRecord(metadata);
|
||||
const result: Record<string, unknown> = {};
|
||||
const setupRpcCompanyId = normalizeSetupRpcCompanyId(record[SETUP_RPC_COMPANY_ID_METADATA_KEY]);
|
||||
if (setupRpcCompanyId) {
|
||||
result[SETUP_RPC_COMPANY_ID_METADATA_KEY] = setupRpcCompanyId;
|
||||
}
|
||||
const fingerprint = readString(record[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]);
|
||||
if (fingerprint) {
|
||||
result[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY] = fingerprint;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeSetupSessionMetadata(
|
||||
existing: Record<string, unknown> | null | undefined,
|
||||
providerMetadata: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
const provider = normalizeProviderMetadata(providerMetadata) ?? {};
|
||||
const persisted = persistedSetupMetadata(existing);
|
||||
const merged = { ...provider, ...persisted };
|
||||
return Object.keys(merged).length > 0 ? merged : null;
|
||||
}
|
||||
|
||||
function normalizePersistedStatus(
|
||||
status: string,
|
||||
fallback: EnvironmentCustomImageSetupSessionStatus = "failed",
|
||||
|
|
@ -179,10 +217,9 @@ function sourceTemplateFromConfig(
|
|||
|
||||
async function resolveActiveTemplate(
|
||||
db: Db,
|
||||
input: { companyId: string; environmentId: string; provider?: string | null },
|
||||
input: { environmentId: string; provider?: string | null },
|
||||
): Promise<EnvironmentCustomImageTemplate | null> {
|
||||
const conditions = [
|
||||
eq(environmentCustomImageTemplates.companyId, input.companyId),
|
||||
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
|
||||
eq(environmentCustomImageTemplates.status, "active"),
|
||||
];
|
||||
|
|
@ -203,6 +240,7 @@ export function environmentCustomImageService(
|
|||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
||||
) {
|
||||
const environments = environmentService(db);
|
||||
const secrets = secretService(db);
|
||||
|
||||
async function getTemplateById(id: string): Promise<EnvironmentCustomImageTemplate | null> {
|
||||
const row = await db
|
||||
|
|
@ -223,14 +261,12 @@ export function environmentCustomImageService(
|
|||
}
|
||||
|
||||
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]),
|
||||
))
|
||||
|
|
@ -240,14 +276,12 @@ export function environmentCustomImageService(
|
|||
}
|
||||
|
||||
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))
|
||||
|
|
@ -261,8 +295,25 @@ export function environmentCustomImageService(
|
|||
return environment;
|
||||
}
|
||||
|
||||
async function resolveSecretContextCompanyId(
|
||||
environmentId: string,
|
||||
explicitCompanyId?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (explicitCompanyId) return explicitCompanyId;
|
||||
const bindingCompanyIds = await secrets.listBindingCompanyIdsForTarget({
|
||||
targetType: "environment",
|
||||
targetId: environmentId,
|
||||
});
|
||||
if (bindingCompanyIds.length > 1) {
|
||||
throw conflict("Environment secret bindings span multiple companies and require explicit companyId context.");
|
||||
}
|
||||
return bindingCompanyIds[0] ?? null;
|
||||
}
|
||||
|
||||
async function resolveSetupProvider(input: {
|
||||
companyId: string;
|
||||
secretContextCompanyId?: string | null;
|
||||
storedRpcCompanyId?: string | null;
|
||||
storedProvider?: string | null;
|
||||
environment: Environment;
|
||||
requireCapture?: boolean;
|
||||
requireDelete?: boolean;
|
||||
|
|
@ -270,9 +321,16 @@ export function environmentCustomImageService(
|
|||
if (!options.pluginWorkerManager) {
|
||||
throw unprocessable("Environment customImage setup requires a running plugin worker manager.");
|
||||
}
|
||||
const storedRpcCompanyId = normalizeSetupRpcCompanyId(input.storedRpcCompanyId);
|
||||
const secretContextCompanyId = storedRpcCompanyId
|
||||
? (storedRpcCompanyId === "instance" ? null : storedRpcCompanyId)
|
||||
: await resolveSecretContextCompanyId(
|
||||
input.environment.id,
|
||||
input.secretContextCompanyId,
|
||||
);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(
|
||||
db,
|
||||
input.companyId,
|
||||
secretContextCompanyId,
|
||||
input.environment,
|
||||
{ issueId: null, heartbeatRunId: null },
|
||||
);
|
||||
|
|
@ -280,6 +338,12 @@ export function environmentCustomImageService(
|
|||
throw unprocessable("Environment customImage setup is only supported for sandbox environments.");
|
||||
}
|
||||
const provider = parsed.config.provider;
|
||||
const storedProvider = readString(input.storedProvider);
|
||||
if (storedProvider && provider !== storedProvider) {
|
||||
throw conflict(
|
||||
`Environment customImage provider changed from "${storedProvider}" to "${provider}". Switch the environment back before continuing this customImage lifecycle operation.`,
|
||||
);
|
||||
}
|
||||
const resolved = await resolvePluginSandboxProviderDriverByKey({
|
||||
db,
|
||||
driverKey: provider,
|
||||
|
|
@ -300,6 +364,7 @@ export function environmentCustomImageService(
|
|||
}
|
||||
return {
|
||||
provider,
|
||||
rpcCompanyId: storedRpcCompanyId ?? secretContextCompanyId ?? "instance",
|
||||
pluginId: resolved.plugin.id,
|
||||
driver: resolved.driver,
|
||||
runtimeConfig: parsed.config,
|
||||
|
|
@ -308,17 +373,20 @@ export function environmentCustomImageService(
|
|||
}
|
||||
|
||||
async function callProviderStart(input: {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
sourceTemplateRef: string | null;
|
||||
sourceTemplateKind: EnvironmentCustomImageTemplateKind | null;
|
||||
secretContextCompanyId?: string | null;
|
||||
}): Promise<PluginEnvironmentInteractiveSetupSession> {
|
||||
const provider = await resolveSetupProvider({ companyId: input.companyId, environment: input.environment });
|
||||
const provider = await resolveSetupProvider({
|
||||
secretContextCompanyId: input.secretContextCompanyId,
|
||||
environment: input.environment,
|
||||
});
|
||||
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentStartInteractiveSetup", {
|
||||
driverKey: provider.provider,
|
||||
companyId: input.companyId,
|
||||
companyId: provider.rpcCompanyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: null,
|
||||
config: provider.driverConfig,
|
||||
|
|
@ -338,10 +406,14 @@ export function environmentCustomImageService(
|
|||
includeConnectionPayload: boolean;
|
||||
}): Promise<PluginEnvironmentInteractiveSetupSession> {
|
||||
const environment = await requireEnvironment(input.session.environmentId);
|
||||
const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment });
|
||||
const provider = await resolveSetupProvider({
|
||||
environment,
|
||||
storedProvider: input.session.provider,
|
||||
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
|
||||
});
|
||||
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentGetInteractiveSetup", {
|
||||
driverKey: provider.provider,
|
||||
companyId: input.session.companyId,
|
||||
companyId: provider.rpcCompanyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: provider.driverConfig,
|
||||
|
|
@ -361,13 +433,14 @@ export function environmentCustomImageService(
|
|||
}): Promise<PluginEnvironmentCaptureTemplateResult> {
|
||||
const environment = await requireEnvironment(input.session.environmentId);
|
||||
const provider = await resolveSetupProvider({
|
||||
companyId: input.session.companyId,
|
||||
environment,
|
||||
storedProvider: input.session.provider,
|
||||
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
|
||||
requireCapture: true,
|
||||
});
|
||||
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCaptureTemplate", {
|
||||
driverKey: provider.provider,
|
||||
companyId: input.session.companyId,
|
||||
companyId: provider.rpcCompanyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: provider.driverConfig,
|
||||
|
|
@ -388,10 +461,14 @@ export function environmentCustomImageService(
|
|||
reason: string | null;
|
||||
}): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
|
||||
const environment = await requireEnvironment(input.session.environmentId);
|
||||
const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment });
|
||||
const provider = await resolveSetupProvider({
|
||||
environment,
|
||||
storedProvider: input.session.provider,
|
||||
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
|
||||
});
|
||||
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCancelInteractiveSetup", {
|
||||
driverKey: provider.provider,
|
||||
companyId: input.session.companyId,
|
||||
companyId: provider.rpcCompanyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: provider.driverConfig,
|
||||
|
|
@ -404,26 +481,37 @@ export function environmentCustomImageService(
|
|||
}));
|
||||
}
|
||||
|
||||
async function resolveTemplateDeleteProvider(template: EnvironmentCustomImageTemplate) {
|
||||
if (!template.templateRef) {
|
||||
throw unprocessable("Cannot delete an environment customImage template without a provider template ref.");
|
||||
}
|
||||
const environment = await requireEnvironment(template.environmentId);
|
||||
return await resolveSetupProvider({
|
||||
environment,
|
||||
storedProvider: template.provider,
|
||||
storedRpcCompanyId: readSetupRpcCompanyId(template.metadata),
|
||||
requireDelete: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function callProviderDeleteTemplate(input: {
|
||||
template: EnvironmentCustomImageTemplate;
|
||||
provider: Awaited<ReturnType<typeof resolveTemplateDeleteProvider>>;
|
||||
reason: string | null;
|
||||
}) {
|
||||
if (!input.template.templateRef) {
|
||||
const templateRef = input.template.templateRef;
|
||||
if (!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,
|
||||
});
|
||||
const provider = input.provider;
|
||||
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentDeleteTemplate", {
|
||||
driverKey: provider.provider,
|
||||
companyId: input.template.companyId,
|
||||
companyId: provider.rpcCompanyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: provider.driverConfig,
|
||||
templateRef: input.template.templateRef,
|
||||
templateRef,
|
||||
templateKind: input.template.templateKind as PluginEnvironmentTemplateRefKind,
|
||||
metadata: input.template.metadata ?? undefined,
|
||||
reason: input.reason,
|
||||
|
|
@ -434,7 +522,7 @@ export function environmentCustomImageService(
|
|||
}
|
||||
|
||||
async function updateSessionFromProvider(
|
||||
sessionId: string,
|
||||
session: Pick<EnvironmentCustomImageSetupSession, "id" | "metadata">,
|
||||
providerSession: PluginEnvironmentInteractiveSetupSession,
|
||||
fallbackStatus: EnvironmentCustomImageSetupSessionStatus = "failed",
|
||||
): Promise<EnvironmentCustomImageSetupSession> {
|
||||
|
|
@ -447,11 +535,11 @@ export function environmentCustomImageService(
|
|||
status,
|
||||
connectionSummary: normalizeConnectionSummary(providerSession.connectionSummary),
|
||||
expiresAt: providerSession.expiresAt ? new Date(providerSession.expiresAt) : undefined,
|
||||
metadata: normalizeProviderMetadata(providerSession.metadata),
|
||||
metadata: mergeSetupSessionMetadata(session.metadata, providerSession.metadata),
|
||||
failureReason: status === "failed" ? "Provider setup session failed or is missing." : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(environmentCustomImageSetupSessions.id, sessionId))
|
||||
.where(eq(environmentCustomImageSetupSessions.id, session.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Environment customImage setup session not found");
|
||||
|
|
@ -509,7 +597,6 @@ export function environmentCustomImageService(
|
|||
|
||||
return {
|
||||
getOverview: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
}): Promise<EnvironmentCustomImageOverview> => {
|
||||
await requireEnvironment(input.environmentId);
|
||||
|
|
@ -522,7 +609,6 @@ export function environmentCustomImageService(
|
|||
},
|
||||
|
||||
getActiveTemplate: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
provider?: string | null;
|
||||
}): Promise<EnvironmentCustomImageTemplate | null> => resolveActiveTemplate(db, input),
|
||||
|
|
@ -530,15 +616,18 @@ export function environmentCustomImageService(
|
|||
getSessionById,
|
||||
|
||||
startSetupSession: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
templateId?: string | null;
|
||||
ttlSeconds?: number | null;
|
||||
actor: { userId?: string | null; agentId?: string | null };
|
||||
secretContextCompanyId?: string | null;
|
||||
now?: Date;
|
||||
}): Promise<EnvironmentCustomImageSetupSessionResult> => {
|
||||
const environment = await requireEnvironment(input.environmentId);
|
||||
const provider = await resolveSetupProvider({ companyId: input.companyId, environment });
|
||||
const provider = await resolveSetupProvider({
|
||||
secretContextCompanyId: input.secretContextCompanyId,
|
||||
environment,
|
||||
});
|
||||
const activeSession = await getActiveSetupSession(input);
|
||||
if (activeSession) {
|
||||
throw conflict("An environment customImage setup session is already active for this environment.");
|
||||
|
|
@ -546,7 +635,6 @@ export function environmentCustomImageService(
|
|||
const selectedTemplate = input.templateId
|
||||
? await getTemplateById(input.templateId)
|
||||
: await resolveActiveTemplate(db, {
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
provider: provider.provider,
|
||||
});
|
||||
|
|
@ -556,13 +644,12 @@ export function environmentCustomImageService(
|
|||
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.");
|
||||
throw unprocessable("Setup template must be the active template for this environment.");
|
||||
}
|
||||
const source = selectedTemplate
|
||||
? {
|
||||
|
|
@ -579,9 +666,12 @@ export function environmentCustomImageService(
|
|||
const expiresAt = addSeconds(now, ttlSeconds);
|
||||
const sessionId = randomUUID();
|
||||
const fingerprint = fingerprintEnvironmentSandboxProviderConfig(provider.runtimeConfig);
|
||||
const setupMetadata = {
|
||||
[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]: fingerprint,
|
||||
[SETUP_RPC_COMPANY_ID_METADATA_KEY]: provider.rpcCompanyId,
|
||||
};
|
||||
await db.insert(environmentCustomImageSetupSessions).values({
|
||||
id: sessionId,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
templateId: selectedTemplate?.id ?? null,
|
||||
provider: provider.provider,
|
||||
|
|
@ -590,23 +680,21 @@ export function environmentCustomImageService(
|
|||
startedByAgentId: input.actor.agentId ?? null,
|
||||
baseTemplateRef: source.sourceTemplateRef,
|
||||
expiresAt,
|
||||
metadata: {
|
||||
sourceEnvironmentConfigFingerprint: fingerprint,
|
||||
},
|
||||
metadata: setupMetadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
try {
|
||||
const providerSession = await callProviderStart({
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
sessionId,
|
||||
expiresAt,
|
||||
sourceTemplateRef: source.sourceTemplateRef,
|
||||
sourceTemplateKind: source.sourceTemplateKind,
|
||||
secretContextCompanyId: input.secretContextCompanyId,
|
||||
});
|
||||
const session = await updateSessionFromProvider(sessionId, providerSession);
|
||||
const session = await updateSessionFromProvider({ id: sessionId, metadata: setupMetadata }, providerSession);
|
||||
return {
|
||||
session,
|
||||
connectionPayload: providerSession.connectionPayload ?? null,
|
||||
|
|
@ -644,7 +732,7 @@ export function environmentCustomImageService(
|
|||
includeConnectionPayload: input.includeConnectionPayload ?? false,
|
||||
});
|
||||
return {
|
||||
session: await updateSessionFromProvider(session.id, providerSession),
|
||||
session: await updateSessionFromProvider(session, providerSession),
|
||||
connectionPayload: providerSession.connectionPayload ?? null,
|
||||
};
|
||||
},
|
||||
|
|
@ -666,7 +754,6 @@ export function environmentCustomImageService(
|
|||
}
|
||||
await markSessionStatus({ sessionId: session.id, status: "capturing" });
|
||||
const currentActive = await resolveActiveTemplate(db, {
|
||||
companyId: session.companyId,
|
||||
environmentId: session.environmentId,
|
||||
provider: session.provider,
|
||||
});
|
||||
|
|
@ -680,8 +767,9 @@ export function environmentCustomImageService(
|
|||
})
|
||||
: null;
|
||||
const provider = await resolveSetupProvider({
|
||||
companyId: session.companyId,
|
||||
environment,
|
||||
storedProvider: session.provider,
|
||||
storedRpcCompanyId: readSetupRpcCompanyId(session.metadata),
|
||||
requireCapture: true,
|
||||
});
|
||||
const runtimeConfigBinding = templateConfigBindingFromDriver({
|
||||
|
|
@ -699,7 +787,6 @@ export function environmentCustomImageService(
|
|||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(environmentCustomImageTemplates.companyId, session.companyId),
|
||||
eq(environmentCustomImageTemplates.environmentId, session.environmentId),
|
||||
eq(environmentCustomImageTemplates.provider, session.provider),
|
||||
eq(environmentCustomImageTemplates.status, "active"),
|
||||
|
|
@ -709,7 +796,6 @@ export function environmentCustomImageService(
|
|||
.insert(environmentCustomImageTemplates)
|
||||
.values({
|
||||
id: templateId,
|
||||
companyId: session.companyId,
|
||||
environmentId: session.environmentId,
|
||||
provider: session.provider,
|
||||
templateKind: readTemplateKind(captured.templateKind),
|
||||
|
|
@ -723,6 +809,7 @@ export function environmentCustomImageService(
|
|||
metadata: normalizeProviderMetadata({
|
||||
...(captured.metadata ?? {}),
|
||||
...(input.metadata ? { userMetadata: input.metadata } : {}),
|
||||
...persistedSetupMetadata(session.metadata),
|
||||
[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
|
||||
}),
|
||||
createdAt: now,
|
||||
|
|
@ -790,7 +877,6 @@ export function environmentCustomImageService(
|
|||
},
|
||||
|
||||
rollbackTemplate: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
now?: Date;
|
||||
}): Promise<{
|
||||
|
|
@ -804,7 +890,6 @@ export function environmentCustomImageService(
|
|||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(and(
|
||||
eq(environmentCustomImageTemplates.companyId, input.companyId),
|
||||
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
|
||||
eq(environmentCustomImageTemplates.provider, active.provider),
|
||||
eq(environmentCustomImageTemplates.status, "superseded"),
|
||||
|
|
@ -843,7 +928,6 @@ export function environmentCustomImageService(
|
|||
},
|
||||
|
||||
disableTemplate: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
deleteProviderTemplate?: boolean;
|
||||
now?: Date;
|
||||
|
|
@ -851,6 +935,9 @@ export function environmentCustomImageService(
|
|||
await requireEnvironment(input.environmentId);
|
||||
const active = await resolveActiveTemplate(db, input);
|
||||
if (!active) throw notFound("Active environment customImage template not found");
|
||||
const deleteProvider = input.deleteProviderTemplate
|
||||
? await resolveTemplateDeleteProvider(active)
|
||||
: null;
|
||||
const now = input.now ?? new Date();
|
||||
const row = await db
|
||||
.update(environmentCustomImageTemplates)
|
||||
|
|
@ -860,8 +947,8 @@ export function environmentCustomImageService(
|
|||
.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" });
|
||||
if (deleteProvider) {
|
||||
await callProviderDeleteTemplate({ template, provider: deleteProvider, reason: "disabled" });
|
||||
}
|
||||
return template;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@ export async function probeEnvironment(
|
|||
companyId?: string | null;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
resolvedConfig?: ParsedEnvironmentConfig;
|
||||
applyCustomImageTemplate?: boolean;
|
||||
} = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const resolvedCompanyId = options.companyId ?? null;
|
||||
const parsed = options.resolvedConfig ?? (
|
||||
resolvedCompanyId
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment)
|
||||
resolvedCompanyId || options.applyCustomImageTemplate === true
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, {
|
||||
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
|
||||
})
|
||||
: parseEnvironmentDriverConfig(environment)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -67,17 +67,14 @@ 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)}`,
|
||||
),
|
||||
customImageTemplate: (environmentId: string) =>
|
||||
api.get<EnvironmentCustomImageOverview>(`/environments/${environmentId}/custom-image-template`),
|
||||
startCustomImageSetupSession: (
|
||||
environmentId: string,
|
||||
companyId: string,
|
||||
body: StartEnvironmentCustomImageSetupSession = {},
|
||||
) =>
|
||||
api.post<EnvironmentCustomImageSetupSessionResult>(
|
||||
`/environments/${environmentId}/custom-image-setup-sessions?companyId=${encodeURIComponent(companyId)}`,
|
||||
`/environments/${environmentId}/custom-image-setup-sessions`,
|
||||
body,
|
||||
),
|
||||
customImageSetupSession: (sessionId: string) =>
|
||||
|
|
@ -100,17 +97,16 @@ export const environmentsApi = {
|
|||
`/environment-custom-image-setup-sessions/${sessionId}/cancel`,
|
||||
body,
|
||||
),
|
||||
rollbackCustomImageTemplate: (environmentId: string, companyId: string) =>
|
||||
rollbackCustomImageTemplate: (environmentId: string) =>
|
||||
api.post<EnvironmentCustomImageRollbackResult>(
|
||||
`/environments/${environmentId}/custom-image-template/rollback?companyId=${encodeURIComponent(companyId)}`,
|
||||
`/environments/${environmentId}/custom-image-template/rollback`,
|
||||
{},
|
||||
),
|
||||
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"}`,
|
||||
`/environments/${environmentId}/custom-image-template?deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -161,8 +161,8 @@ 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,
|
||||
customImageTemplate: (environmentId: string) =>
|
||||
["environments", environmentId, "custom-image-template"] as const,
|
||||
customImageSetupSession: (sessionId: string) =>
|
||||
["environment-custom-image-setup-sessions", sessionId] as const,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ function getOpenDialog(): HTMLElement | null {
|
|||
function createSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "session-1",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
templateId: null,
|
||||
promotedTemplateId: null,
|
||||
|
|
@ -152,7 +151,6 @@ function createSession(overrides: Record<string, unknown> = {}) {
|
|||
function createTemplate(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "template-1",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
|
|
@ -457,7 +455,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
await waitForAssertion(() => {
|
||||
expect(getOpenDialog()?.textContent).toContain("Configure image");
|
||||
});
|
||||
expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
|
||||
expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1");
|
||||
await act(async () => click(findButton(document.body, "Cancel")));
|
||||
await waitForAssertion(() => expect(getOpenDialog()).toBeNull());
|
||||
|
||||
|
|
@ -656,7 +654,6 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
|
||||
expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith(
|
||||
"env-1",
|
||||
"company-1",
|
||||
{ templateId: "template-active" },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,12 +245,10 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"])
|
|||
}
|
||||
|
||||
function EnvironmentImageTemplatePanel({
|
||||
companyId,
|
||||
environment,
|
||||
providerCapability,
|
||||
providerDisplayName,
|
||||
}: {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
providerCapability: EnvironmentProviderCapability | null | undefined;
|
||||
providerDisplayName: string;
|
||||
|
|
@ -258,11 +256,11 @@ function EnvironmentImageTemplatePanel({
|
|||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const state = capabilityState(providerCapability);
|
||||
const overviewKey = queryKeys.environments.customImageTemplate(companyId, environment.id);
|
||||
const overviewKey = queryKeys.environments.customImageTemplate(environment.id);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: overviewKey,
|
||||
queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId),
|
||||
queryFn: () => environmentsApi.customImageTemplate(environment.id),
|
||||
enabled: state.kind === "supported",
|
||||
retry: false,
|
||||
});
|
||||
|
|
@ -290,7 +288,7 @@ function EnvironmentImageTemplatePanel({
|
|||
|
||||
const startSetupMutation = useMutation({
|
||||
mutationFn: (input: { templateId?: string | null } = {}) =>
|
||||
environmentsApi.startCustomImageSetupSession(environment.id, companyId, {
|
||||
environmentsApi.startCustomImageSetupSession(environment.id, {
|
||||
templateId: input.templateId ?? null,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
|
|
@ -368,7 +366,7 @@ function EnvironmentImageTemplatePanel({
|
|||
});
|
||||
|
||||
const rollbackTemplateMutation = useMutation({
|
||||
mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId),
|
||||
mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
|
||||
activeTemplate: result.activeTemplate,
|
||||
|
|
@ -392,7 +390,7 @@ function EnvironmentImageTemplatePanel({
|
|||
});
|
||||
|
||||
const disableTemplateMutation = useMutation({
|
||||
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId),
|
||||
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id),
|
||||
onSuccess: (template) => {
|
||||
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
|
||||
activeTemplate: null,
|
||||
|
|
@ -1246,7 +1244,6 @@ export function CompanyEnvironments() {
|
|||
running machine as a reusable image for future runs.
|
||||
</div>
|
||||
<EnvironmentImageTemplatePanel
|
||||
companyId={selectedCompanyId}
|
||||
environment={editingEnvironment}
|
||||
providerCapability={editingSandboxCapability}
|
||||
providerDisplayName={editingSandboxDisplayName}
|
||||
|
|
|
|||
Loading…
Reference in New Issue