fix(sandbox): keep custom-image snapshots applied to config tests, probes, and saves (#9385)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox environments can capture reusable custom images (provider snapshots) so agents boot with pre-installed tools and CLI logins > - The custom-image runtime fingerprint check included provider secret-ref paths (e.g. the Daytona `apiKey`), while capture-time fingerprinting excluded them, so any config carrying a credential never matched its captured snapshot > - As a result, agent config tests and environment probes silently booted the provider base image instead of the snapshot, test sandboxes were deleted before operators could inspect them, and any environment save orphaned the snapshot without warning > - The UI compounded the confusion by displaying an internal template id that matches nothing in the provider dashboard > - This pull request aligns runtime fingerprints with capture-time exclusions, re-stamps fingerprints on saves that cannot affect the snapshot (warning when they can), archives test/probe sandboxes instead of deleting them, and surfaces the provider snapshot ref in the UI > - The benefit is that custom images actually apply to config tests and probes, survive unrelated config edits, and are debuggable against the provider dashboard ## Linked Issues or Issue Description No public GitHub issue exists for this; describing it in-PR per the bug template. Related: Refs #9329 (saved-environment probe company context — this branch carries an equivalent fix), Refs #8794 (introduced reusable sandbox custom images). **What happened?** With a Daytona environment whose provider config stores the API key as a secret reference and an active captured custom-image snapshot: - Agent config tests and environment probes booted the provider base image (`daytonaio/sandbox:0.8.0`) instead of the captured snapshot, so CLI upgrades/logins baked into the snapshot were missing and the probe reported "login required" and an outdated CLI. - The environment card showed an internal template id (e.g. `b5be03e1-ca5…`) that does not correspond to any snapshot name in the provider dashboard, making the active image impossible to correlate. - Test/probe sandboxes were deleted immediately after the run, so the sandbox a test used could not be inspected afterwards. - Saving the environment config (even fields unrelated to the image) changed the stored fingerprint, silently detaching the snapshot with no warning. **Expected behavior** Config tests and probes boot the captured snapshot when one is active; the UI shows the provider-facing snapshot/template ref; test sandboxes stay inspectable for a short window; unrelated config edits keep the snapshot linked, and edits that genuinely invalidate it produce an explicit warning. **Steps to reproduce** 1. Configure a sandbox environment on Daytona with the API key stored as a company secret reference. 2. Capture a custom image snapshot from the environment page and mark it active (e.g. after installing/logging into a CLI in the setup sandbox). 3. Run the agent config test or an environment probe: the sandbox boots the base image, not the snapshot, and the sandbox is deleted immediately after the test. 4. Save the environment config with an unrelated field change: the snapshot silently stops applying. **Paperclip version or commit** `master` at the merge-base of this branch. **Deployment mode** Self-hosted local instance (macOS, pnpm dev server) with the Daytona sandbox provider plugin. ## What Changed - Runtime custom-image fingerprint checks now exclude provider secret-ref paths, matching capture-time exclusions, so configs carrying credentials match their captured snapshots (`environment-custom-image-runtime.ts`). - Agent config tests and saved-environment probes force fresh, non-reused sandboxes and pass company context so lease-backed probes can resolve company secrets and boot the real snapshot (`environment-probe.ts`, `routes/agents.ts`, `routes/environments.ts`). - Test/probe sandboxes are released by archiving (stop + 60-minute provider-side auto-delete) instead of immediate deletion, so operators can inspect the exact sandbox a test used (Daytona plugin). - On environment PATCH save, changes that cannot affect the captured snapshot re-stamp the template's source fingerprint so the snapshot stays linked; boot-source or provider-identity changes (new manifest field `templateIdentityPaths`) mark the template detached and the save response reports it (`environment-custom-images.ts`, shared plugin types/validators). - The custom-image overview exposes `activeTemplateMatchesConfig`; the environments UI shows the provider snapshot/template ref (internal id moved to a tooltip), warns via toast when a save detaches the snapshot, and shows a persistent "Not in use" warning when the active template no longer matches the saved config (`CompanyEnvironments.tsx`, `api/environments.ts`). ## Verification - `pnpm vitest run server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-probe.test.ts server/src/__tests__/environment-routes.test.ts server/src/__tests__/agent-test-environment-routes.test.ts` — server coverage for fingerprint exclusions, re-stamp/detach on save, probe company context, and fresh-sandbox test behavior. - `pnpm vitest run packages/plugins/sandbox-providers/daytona/src/plugin.test.ts` — archive-on-release and snapshot ref handling. - `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx` — snapshot ref display, detach toast, and "Not in use" warning. - Manually verified end-to-end on a live self-hosted instance against real Daytona: config test boots the captured snapshot (CLI login and version persist), the test sandbox remains visible in the provider dashboard as archived, and saving unrelated fields keeps the snapshot applied. ## Risks - Fingerprint exclusion widening: a provider credential rotation alone no longer detaches a captured snapshot; that is the intended behavior (the snapshot content does not depend on the credential), and provider-identity fields (e.g. Daytona `apiUrl`) still detach via `templateIdentityPaths`. - Archived test sandboxes consume provider-side resources for up to their auto-delete window instead of being freed immediately; bounded (60 minutes) and only for test/probe sandboxes. - New optional manifest field `templateIdentityPaths` is backward-compatible; providers that omit it keep current matching behavior. ## Model Used - Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use via Claude Code / Claude Agent SDK. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
70ce005bef
commit
17dde9d3f2
|
|
@ -32,6 +32,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||
field: "snapshot",
|
||||
unsetFields: ["image"],
|
||||
},
|
||||
templateIdentityPaths: ["apiUrl"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ function createMockSandbox(overrides: {
|
|||
recover: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
archive: vi.fn().mockResolvedValue(undefined),
|
||||
setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined),
|
||||
createSshAccess: vi.fn().mockResolvedValue({
|
||||
token: "ssh-token-secret",
|
||||
command: "ssh ssh-token-secret@ssh.app.daytona.io",
|
||||
|
|
@ -133,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => {
|
|||
autoArchiveInterval: 60,
|
||||
autoDeleteInterval: -1,
|
||||
reuseLease: true,
|
||||
archiveOnRelease: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -956,6 +959,54 @@ describe("Daytona sandbox provider plugin", () => {
|
|||
expect(ephemeral.delete).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" });
|
||||
mockGet.mockResolvedValue(sandbox);
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "sandbox-test-probe",
|
||||
config: {
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sandbox.stop).toHaveBeenCalledWith(300);
|
||||
expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60);
|
||||
expect(sandbox.archive).toHaveBeenCalled();
|
||||
expect(sandbox.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to delete when archiving an archiveOnRelease lease fails", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" });
|
||||
sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported"));
|
||||
mockGet.mockResolvedValue(sandbox);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "sandbox-test-probe",
|
||||
config: {
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sandbox.stop).not.toHaveBeenCalled();
|
||||
expect(sandbox.archive).toHaveBeenCalled();
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to delete when stopping a reusable lease from an error state fails", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const errored = createMockSandbox({ id: "sandbox-error", state: "error" });
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface DaytonaDriverConfig {
|
|||
autoArchiveInterval: number | null;
|
||||
autoDeleteInterval: number | null;
|
||||
reuseLease: boolean;
|
||||
archiveOnRelease: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceSentinelResult = {
|
||||
|
|
@ -93,6 +94,12 @@ 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
|
||||
|
||||
// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so
|
||||
// operators can inspect them from the Daytona dashboard, then expired by
|
||||
// Daytona itself after this interval (counted from the stop that precedes the
|
||||
// archive) so debugging copies don't accumulate.
|
||||
const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60;
|
||||
|
||||
// Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.)
|
||||
// so a stalled remote or missing credential never consumes the full 900 s adapter
|
||||
// RPC ceiling; callers always see an actionable error within this window.
|
||||
|
|
@ -145,6 +152,7 @@ function parseDriverConfig(raw: Record<string, unknown>): DaytonaDriverConfig {
|
|||
autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES,
|
||||
autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES,
|
||||
reuseLease: raw.reuseLease === true,
|
||||
archiveOnRelease: raw.archiveOnRelease === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -434,6 +442,9 @@ function leaseMetadata(input: {
|
|||
target: input.sandbox.target,
|
||||
timeoutMs: input.config.timeoutMs,
|
||||
reuseLease: input.config.reuseLease,
|
||||
// Persisted so the release path (which rebuilds config from lease
|
||||
// metadata) still knows to archive instead of delete.
|
||||
...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}),
|
||||
remoteCwd: input.remoteCwd,
|
||||
resumedLease: input.resumedLease,
|
||||
// Record the resources Paperclip attempted to request so future diagnosis
|
||||
|
|
@ -956,6 +967,21 @@ const plugin = definePlugin({
|
|||
return;
|
||||
}
|
||||
|
||||
if (config.archiveOnRelease) {
|
||||
try {
|
||||
if (sandbox.state !== "stopped") {
|
||||
await sandbox.stop(toTimeoutSeconds(config.timeoutMs));
|
||||
}
|
||||
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
|
||||
await sandbox.archive();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ export interface FakeSandboxEnvironmentConfig {
|
|||
reuseLease: boolean;
|
||||
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
|
||||
streamRunLogs?: boolean;
|
||||
/**
|
||||
* Archive the sandbox on lease release instead of deleting it, so operators
|
||||
* can inspect it from the provider dashboard. Injected by test/probe paths;
|
||||
* providers without archive support delete as usual.
|
||||
*/
|
||||
archiveOnRelease?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSandboxEnvironmentConfig {
|
||||
|
|
@ -38,6 +44,12 @@ export interface PluginSandboxEnvironmentConfig {
|
|||
timeoutMs?: number;
|
||||
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
|
||||
streamRunLogs?: boolean;
|
||||
/**
|
||||
* Archive the sandbox on lease release instead of deleting it, so operators
|
||||
* can inspect it from the provider dashboard. Injected by test/probe paths;
|
||||
* providers without archive support delete as usual.
|
||||
*/
|
||||
archiveOnRelease?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ export interface PluginEnvironmentDriverDeclaration {
|
|||
* runtime config. Omit to use the standard key for `templateRefKind`.
|
||||
*/
|
||||
templateConfigBinding?: PluginEnvironmentTemplateConfigBinding;
|
||||
/**
|
||||
* Config paths (dot notation) that scope where captured templates live for
|
||||
* this provider, such as an API endpoint. When one of these changes on a
|
||||
* saved environment, captured templates cannot be re-linked to the updated
|
||||
* config and a fresh capture is required.
|
||||
*/
|
||||
templateIdentityPaths?: string[];
|
||||
/** Provider supports best-effort deletion/cleanup of captured templates. */
|
||||
supportsTemplateDelete?: boolean;
|
||||
/** JSON Schema describing the driver's provider-specific configuration. */
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
|
|||
supportsTemplateCapture: z.boolean().optional(),
|
||||
templateRefKind: z.string().min(1).max(100).optional(),
|
||||
templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(),
|
||||
templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(),
|
||||
supportsTemplateDelete: z.boolean().optional(),
|
||||
configSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,7 +137,15 @@ describe("agent test-environment route", () => {
|
|||
mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({
|
||||
lease: {
|
||||
id: "lease-1",
|
||||
metadata: { remoteCwd: "/home/user/paperclip-workspace" },
|
||||
provider: "daytona",
|
||||
providerLeaseId: "provider-lease-1",
|
||||
metadata: {
|
||||
remoteCwd: "/home/user/paperclip-workspace",
|
||||
sandboxId: "sandbox-1",
|
||||
sandboxName: "paperclip-probe",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-1",
|
||||
},
|
||||
},
|
||||
leaseContext: {
|
||||
executionWorkspaceId: null,
|
||||
|
|
@ -263,6 +271,19 @@ describe("agent test-environment route", () => {
|
|||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
||||
// Test leases boot fresh and stay debuggable: never resume a retained
|
||||
// agent lease, archive (not delete) the sandbox on release.
|
||||
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applyCustomImageTemplate: true,
|
||||
environment: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
executionTarget: expect.objectContaining({
|
||||
kind: "remote",
|
||||
|
|
@ -271,6 +292,24 @@ describe("agent test-environment route", () => {
|
|||
environmentName: "Sandbox QA",
|
||||
});
|
||||
expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" });
|
||||
expect(res.body.checks).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "sandbox_test_identity",
|
||||
level: "info",
|
||||
message: 'Sandbox test identity for "Sandbox QA".',
|
||||
detail: expect.stringContaining("paperclipLeaseId=lease-1"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "external_test_hello_probe_passed",
|
||||
level: "info",
|
||||
message: "OK",
|
||||
}),
|
||||
]);
|
||||
expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1");
|
||||
expect(res.body.checks[0].detail).toContain("provider=daytona");
|
||||
expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1");
|
||||
expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe");
|
||||
expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1");
|
||||
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
||||
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
lease: expect.objectContaining({ id: "lease-1" }),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
environmentCustomImageService,
|
||||
} from "../services/environment-custom-images.js";
|
||||
import {
|
||||
ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
environmentCustomImageTemplateMatchesBaseConfig,
|
||||
fingerprintEnvironmentSandboxProviderConfig,
|
||||
} from "../services/environment-custom-image-runtime.js";
|
||||
import {
|
||||
|
|
@ -55,6 +57,7 @@ function pluginManifest() {
|
|||
field: "customTemplate",
|
||||
unsetFields: ["image"],
|
||||
},
|
||||
templateIdentityPaths: ["apiUrl"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
|
|
@ -436,7 +439,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
provider: "fake-plugin",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-active",
|
||||
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any),
|
||||
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, {
|
||||
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
}),
|
||||
status: "active",
|
||||
});
|
||||
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
|
|
@ -448,20 +453,54 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
|
|||
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,
|
||||
// Runtime-only resource/lease edits must NOT silently discard the captured
|
||||
// template.
|
||||
await db.update(environments)
|
||||
.set({
|
||||
config: {
|
||||
...(environment.config as Record<string, unknown>),
|
||||
cpu: 4,
|
||||
timeoutMs: 600000,
|
||||
reuseLease: true,
|
||||
},
|
||||
})
|
||||
.where(eq(environments.id, environment.id));
|
||||
const afterResourceChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!);
|
||||
const afterResourceChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
id: afterResourceChangeEnvironment.id,
|
||||
driver: "sandbox",
|
||||
config: environment.config,
|
||||
config: afterResourceChangeEnvironment.config,
|
||||
}, { heartbeatRunId: randomUUID() });
|
||||
expect(afterConfigChange.driver).toBe("sandbox");
|
||||
expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" });
|
||||
expect(afterConfigChange.config).not.toHaveProperty("image");
|
||||
expect(afterResourceChange.driver).toBe("sandbox");
|
||||
expect(afterResourceChange.config).toMatchObject({ snapshot: "snapshot-active" });
|
||||
expect(afterResourceChange.config).not.toHaveProperty("image");
|
||||
|
||||
// Changing the base image is a meaningful source-template change. In that
|
||||
// case, the old capture must not mask the newly saved image.
|
||||
await db.update(environmentCustomImageTemplates)
|
||||
.set({
|
||||
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, {
|
||||
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
}),
|
||||
})
|
||||
.where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active"));
|
||||
await db.update(environments)
|
||||
.set({
|
||||
config: {
|
||||
...(environment.config as Record<string, unknown>),
|
||||
image: "fake:new-base",
|
||||
},
|
||||
})
|
||||
.where(eq(environments.id, environment.id));
|
||||
const afterImageChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!);
|
||||
const afterImageChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
id: afterImageChangeEnvironment.id,
|
||||
driver: "sandbox",
|
||||
config: afterImageChangeEnvironment.config,
|
||||
}, { heartbeatRunId: randomUUID() });
|
||||
expect(afterImageChange.driver).toBe("sandbox");
|
||||
expect(afterImageChange.config).toMatchObject({ image: "fake:new-base" });
|
||||
expect(afterImageChange.config).not.toHaveProperty("snapshot");
|
||||
});
|
||||
|
||||
it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => {
|
||||
|
|
@ -562,3 +601,268 @@ describe("fingerprintEnvironmentSandboxProviderConfig", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environmentCustomImageTemplateMatchesBaseConfig", () => {
|
||||
it("keeps captures across runtime-only edits but not base image changes", () => {
|
||||
const baseConfig = {
|
||||
provider: "daytona",
|
||||
image: "daytonaio/sandbox:0.8.0",
|
||||
timeoutMs: 300000,
|
||||
reuseLease: false,
|
||||
} as any;
|
||||
const template = {
|
||||
id: "template-1",
|
||||
environmentId: "env-1",
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-active",
|
||||
sourceTemplateRef: "daytonaio/sandbox:0.8.0",
|
||||
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, {
|
||||
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
}),
|
||||
status: "active",
|
||||
createdByUserId: null,
|
||||
createdByAgentId: null,
|
||||
capturedAt: null,
|
||||
lastUsedAt: null,
|
||||
supersededByTemplateId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
expect(environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig: {
|
||||
...baseConfig,
|
||||
timeoutMs: 600000,
|
||||
reuseLease: true,
|
||||
cpu: 4,
|
||||
},
|
||||
})).toBe(true);
|
||||
expect(environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig: {
|
||||
...baseConfig,
|
||||
image: "daytonaio/sandbox:0.9.0",
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("matches configs carrying a secret-ref credential when the capture excluded that path", () => {
|
||||
// Capture-time fingerprints exclude the provider's secret-ref paths (e.g.
|
||||
// daytona apiKey). The runtime re-check must exclude the same paths or a
|
||||
// config with any credential never matches and the template is dropped.
|
||||
const baseConfig = {
|
||||
provider: "daytona",
|
||||
image: "daytonaio/sandbox:0.8.0",
|
||||
apiKey: "raw-api-key-value",
|
||||
} as any;
|
||||
const template = {
|
||||
id: "template-1",
|
||||
environmentId: "env-1",
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-active",
|
||||
sourceTemplateRef: "daytonaio/sandbox:0.8.0",
|
||||
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, {
|
||||
excludePaths: [
|
||||
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
"apiKey",
|
||||
],
|
||||
}),
|
||||
status: "active",
|
||||
createdByUserId: null,
|
||||
createdByAgentId: null,
|
||||
capturedAt: null,
|
||||
lastUsedAt: null,
|
||||
supersededByTemplateId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
expect(environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig,
|
||||
secretRefExcludePaths: ["apiKey"],
|
||||
})).toBe(true);
|
||||
// A rotated credential still matches — credentials are not part of the
|
||||
// captured image identity.
|
||||
expect(environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig: { ...baseConfig, apiKey: "rotated-key" },
|
||||
secretRefExcludePaths: ["apiKey"],
|
||||
})).toBe(true);
|
||||
// Without the exclusion the same config fails to match (the pre-fix bug).
|
||||
expect(environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-reconcile");
|
||||
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 environmentId = randomUUID();
|
||||
await db.insert(companies).values(
|
||||
{ id: companyId, name: "Acme", issuePrefix: `A${companyId.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, environmentId };
|
||||
}
|
||||
|
||||
it("re-links the active template on save when only non-identity fields change", async () => {
|
||||
const { companyId, 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 baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false };
|
||||
const nextConfig = { ...baseConfig, region: "eu-west" };
|
||||
|
||||
const relinked = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: baseConfig },
|
||||
next: { driver: "sandbox", config: nextConfig },
|
||||
});
|
||||
expect(relinked.action).toBe("relinked");
|
||||
if (relinked.action !== "relinked") throw new Error("expected relink");
|
||||
expect(relinked.template.sourceEnvironmentConfigFingerprint)
|
||||
.not.toBe(promoted.template.sourceEnvironmentConfigFingerprint);
|
||||
|
||||
// Re-running with the same configs is a no-op: the template already
|
||||
// matches the new config.
|
||||
const repeat = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: baseConfig },
|
||||
next: { driver: "sandbox", config: nextConfig },
|
||||
});
|
||||
expect(repeat.action).toBe("none");
|
||||
|
||||
// The captured template keeps applying at runtime under the new config.
|
||||
await db.update(environments)
|
||||
.set({ config: nextConfig })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
id: environmentId,
|
||||
driver: "sandbox",
|
||||
config: nextConfig,
|
||||
}, { heartbeatRunId: randomUUID() });
|
||||
expect(resolved.driver).toBe("sandbox");
|
||||
expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef });
|
||||
expect(resolved.config).not.toHaveProperty("image");
|
||||
});
|
||||
|
||||
it("reports detached on save when a boot-source or provider identity field changes", 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 baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false };
|
||||
|
||||
// Base image change: the user asked for a different base, so the capture
|
||||
// cannot be re-linked.
|
||||
const imageChange = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: baseConfig },
|
||||
next: { driver: "sandbox", config: { ...baseConfig, image: "fake:other" } },
|
||||
});
|
||||
expect(imageChange.action).toBe("detached");
|
||||
|
||||
// Provider-declared identity path change (apiUrl): captured templates do
|
||||
// not exist on a different provider endpoint.
|
||||
const endpointChange = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: baseConfig },
|
||||
next: { driver: "sandbox", config: { ...baseConfig, apiUrl: "https://other.example" } },
|
||||
});
|
||||
expect(endpointChange.action).toBe("detached");
|
||||
|
||||
// Binding-field change counts as a boot-source change too.
|
||||
const bindingChange = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: baseConfig },
|
||||
next: { driver: "sandbox", config: { ...baseConfig, customTemplate: "someone-elses-snapshot" } },
|
||||
});
|
||||
expect(bindingChange.action).toBe("detached");
|
||||
|
||||
// Detach never mutates the stored fingerprint; rollback/disable stay
|
||||
// available and the old config still matches.
|
||||
const template = await service.getActiveTemplate({ environmentId, provider: "fake-plugin" });
|
||||
expect(template?.sourceEnvironmentConfigFingerprint)
|
||||
.toBe(promoted.template.sourceEnvironmentConfigFingerprint);
|
||||
|
||||
// A template that was already detached before the save is left alone.
|
||||
const alreadyDetached = await service.reconcileActiveTemplateForConfigChange({
|
||||
environmentId,
|
||||
previous: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated" } },
|
||||
next: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated", region: "eu" } },
|
||||
});
|
||||
expect(alreadyDetached.action).toBe("none");
|
||||
});
|
||||
|
||||
it("reports whether the active template matches the saved config in the overview", async () => {
|
||||
const { environmentId } = await seed();
|
||||
const workerManager = createWorkerManager();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
const inSync = await service.getOverview({ environmentId });
|
||||
expect(inSync.activeTemplateMatchesConfig).toBe(true);
|
||||
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const outOfSync = await service.getOverview({ environmentId });
|
||||
expect(outOfSync.activeTemplate).not.toBeNull();
|
||||
expect(outOfSync.activeTemplateMatchesConfig).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn());
|
|||
const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn());
|
||||
const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn());
|
||||
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({
|
||||
acquireRunLease: mockRuntimeAcquireRunLease,
|
||||
getDriver: vi.fn(() => ({
|
||||
releaseRunLease: mockRuntimeReleaseRunLease,
|
||||
})),
|
||||
})));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({
|
||||
ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady,
|
||||
|
|
@ -15,6 +23,10 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
|
|||
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
||||
}));
|
||||
|
||||
vi.mock("../services/environment-runtime.js", () => ({
|
||||
environmentRuntimeService: mockEnvironmentRuntimeService,
|
||||
}));
|
||||
|
||||
import { probeEnvironment } from "../services/environment-probe.ts";
|
||||
|
||||
describe("probeEnvironment", () => {
|
||||
|
|
@ -24,6 +36,9 @@ describe("probeEnvironment", () => {
|
|||
mockProbePluginSandboxProviderDriver.mockReset();
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockReset();
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null);
|
||||
mockRuntimeAcquireRunLease.mockReset();
|
||||
mockRuntimeReleaseRunLease.mockReset();
|
||||
mockEnvironmentRuntimeService.mockClear();
|
||||
});
|
||||
|
||||
it("reports local environments as immediately available", async () => {
|
||||
|
|
@ -163,6 +178,91 @@ describe("probeEnvironment", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("boots a fresh runtime lease for saved sandbox probes when requested", async () => {
|
||||
mockRuntimeAcquireRunLease.mockResolvedValue({
|
||||
environment: {},
|
||||
leaseContext: {
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceMode: null,
|
||||
},
|
||||
lease: {
|
||||
id: "lease-1",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-sandbox-plugin",
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
status: "active",
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "daytona",
|
||||
providerLeaseId: "sandbox-runtime-1",
|
||||
acquiredAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
expiresAt: null,
|
||||
releasedAt: null,
|
||||
failureReason: null,
|
||||
cleanupStatus: "pending",
|
||||
metadata: {
|
||||
provider: "daytona",
|
||||
sandboxName: "paperclip-probe",
|
||||
reuseLease: false,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const environment = {
|
||||
id: "env-sandbox-plugin",
|
||||
companyId: "company-1",
|
||||
name: "Daytona",
|
||||
description: null,
|
||||
driver: "sandbox" as const,
|
||||
status: "active" as const,
|
||||
config: {
|
||||
provider: "daytona",
|
||||
image: "daytonaio/sandbox:0.8.0",
|
||||
reuseLease: true,
|
||||
},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await probeEnvironment({} as any, environment, {
|
||||
companyId: "company-1",
|
||||
pluginWorkerManager: {} as any,
|
||||
applyCustomImageTemplate: true,
|
||||
acquireSandboxRuntimeLease: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: "Connected to daytona sandbox paperclip-probe.",
|
||||
});
|
||||
expect(mockProbePluginSandboxProviderDriver).not.toHaveBeenCalled();
|
||||
expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
issueId: null,
|
||||
agentId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
adapterType: null,
|
||||
applyCustomImageTemplate: true,
|
||||
}));
|
||||
expect(mockRuntimeAcquireRunLease.mock.calls[0]?.[0].environment.config).toMatchObject({
|
||||
provider: "daytona",
|
||||
image: "daytonaio/sandbox:0.8.0",
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
});
|
||||
expect(mockRuntimeReleaseRunLease).toHaveBeenCalledWith(expect.objectContaining({
|
||||
lease: expect.objectContaining({ id: "lease-1" }),
|
||||
status: "released",
|
||||
}));
|
||||
});
|
||||
|
||||
it("routes plugin environment probes through the plugin worker host", async () => {
|
||||
mockProbePluginEnvironmentDriver.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -1597,6 +1597,7 @@ describe("environment routes", () => {
|
|||
companyId: null,
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: false,
|
||||
acquireSandboxRuntimeLease: false,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -1693,9 +1694,10 @@ describe("environment routes", () => {
|
|||
expect(res.status).toBe(200);
|
||||
expect(res.body.driver).toBe("sandbox");
|
||||
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
||||
companyId: null,
|
||||
companyId: "company-1",
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: true,
|
||||
acquireSandboxRuntimeLease: true,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -1748,9 +1750,10 @@ describe("environment routes", () => {
|
|||
expect(res.status).toBe(200);
|
||||
expect(res.body.driver).toBe("sandbox");
|
||||
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
|
||||
companyId: null,
|
||||
companyId: "company-1",
|
||||
pluginWorkerManager: undefined,
|
||||
applyCustomImageTemplate: true,
|
||||
acquireSandboxRuntimeLease: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ export function agentRoutes(
|
|||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
|
@ -362,11 +363,26 @@ export function agentRoutes(
|
|||
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
|
||||
// run row. Cleanup goes through the driver's `releaseRunLease` directly
|
||||
// (by lease record), since the batch helper queries by heartbeatRunId.
|
||||
//
|
||||
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
|
||||
// agent lease) and archive it on release instead of deleting it, so the
|
||||
// operator can inspect the exact sandbox from the provider dashboard while
|
||||
// provider-side expiry reaps it later.
|
||||
const testEnvironment = environment.driver === "sandbox"
|
||||
? {
|
||||
...environment,
|
||||
config: {
|
||||
...(environment.config ?? {}),
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
}
|
||||
: environment;
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
environment: testEnvironment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
|
|
@ -398,7 +414,7 @@ export function agentRoutes(
|
|||
try {
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment,
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
status,
|
||||
});
|
||||
|
|
@ -417,7 +433,7 @@ export function agentRoutes(
|
|||
let realizedCwd: string | null = null;
|
||||
try {
|
||||
const realized = await environmentRuntime.realizeWorkspace({
|
||||
environment,
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
// No host workspace to copy for a Test invocation; sandbox/plugin
|
||||
// realize implementations use the lease metadata's remoteCwd to
|
||||
|
|
@ -458,9 +474,9 @@ export function agentRoutes(
|
|||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.config ?? null,
|
||||
id: testEnvironment.id,
|
||||
driver: testEnvironment.driver,
|
||||
config: testEnvironment.config ?? null,
|
||||
},
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leaseMetadata: leaseMetadataForTarget,
|
||||
|
|
@ -505,10 +521,75 @@ export function agentRoutes(
|
|||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
sandboxIdentityCheck: buildSandboxIdentityCheck({
|
||||
environmentName: environment.name,
|
||||
lease: leaseRecord.lease,
|
||||
}),
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSandboxIdentityCheck(input: {
|
||||
environmentName: string;
|
||||
lease: {
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
providerLeaseId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}): AdapterEnvironmentCheck {
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
|
||||
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
|
||||
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
|
||||
const snapshotRef = readMetadataString(metadata, [
|
||||
"snapshot",
|
||||
"snapshotId",
|
||||
"snapshotID",
|
||||
"snapshotRef",
|
||||
"snapshot_ref",
|
||||
"templateRef",
|
||||
"template_ref",
|
||||
"templateId",
|
||||
"templateID",
|
||||
"image",
|
||||
"imageId",
|
||||
"imageID",
|
||||
"imageRef",
|
||||
"image_ref",
|
||||
]);
|
||||
const templateKind = readMetadataString(metadata, [
|
||||
"templateKind",
|
||||
"template_kind",
|
||||
"templateRefKind",
|
||||
"template_ref_kind",
|
||||
]);
|
||||
const detailParts = [
|
||||
`paperclipLeaseId=${input.lease.id}`,
|
||||
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
|
||||
provider ? `provider=${provider}` : null,
|
||||
sandboxId ? `sandboxId=${sandboxId}` : null,
|
||||
sandboxName ? `sandboxName=${sandboxName}` : null,
|
||||
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
code: "sandbox_test_identity",
|
||||
level: "info",
|
||||
message: `Sandbox test identity for "${input.environmentName}".`,
|
||||
detail: detailParts.join("; "),
|
||||
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed sandbox snapshots.",
|
||||
};
|
||||
}
|
||||
|
||||
async function getCurrentUserRedactionOptions() {
|
||||
return {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
|
|
@ -1690,7 +1771,7 @@ export function agentRoutes(
|
|||
{ adapterType: type },
|
||||
);
|
||||
|
||||
const { executionTarget, environmentName, fallbackChecks, release } =
|
||||
const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } =
|
||||
await resolveAdapterTestExecutionContext({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
|
|
@ -1728,7 +1809,10 @@ export function agentRoutes(
|
|||
});
|
||||
|
||||
if (result.status === "fail") releaseStatus = "failed";
|
||||
res.json(result);
|
||||
res.json({
|
||||
...result,
|
||||
checks: sandboxIdentityCheck ? [sandboxIdentityCheck, ...result.checks] : result.checks,
|
||||
});
|
||||
} catch (err) {
|
||||
releaseStatus = "failed";
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -777,12 +777,24 @@ export function environmentRoutes(
|
|||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
let customImageReconciliation: Awaited<
|
||||
ReturnType<typeof customImages.reconcileActiveTemplateForConfigChange>
|
||||
> = { action: "none" };
|
||||
if (patch.config !== undefined || patch.driver !== undefined) {
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
companyIdForSecrets!,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
try {
|
||||
customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({
|
||||
environmentId: environment.id,
|
||||
previous: existing,
|
||||
next: environment,
|
||||
});
|
||||
} catch {
|
||||
// Reconciliation is best-effort; a failure must not fail the save.
|
||||
}
|
||||
}
|
||||
if (patch.envVars !== undefined) {
|
||||
await secrets.syncEnvBindingsForTarget(
|
||||
|
|
@ -797,7 +809,9 @@ export function environmentRoutes(
|
|||
entityId: environment.id,
|
||||
details: summarizeEnvironmentUpdate(patch as Record<string, unknown>, environment),
|
||||
});
|
||||
res.json(environment);
|
||||
res.json(customImageReconciliation.action === "none"
|
||||
? environment
|
||||
: { ...environment, customImageReconciliation });
|
||||
});
|
||||
|
||||
router.delete("/environments/:id", async (req, res) => {
|
||||
|
|
@ -871,6 +885,8 @@ export function environmentRoutes(
|
|||
assertCanAccessInstanceEnvironments(req);
|
||||
const actor = getActorInfo(req);
|
||||
const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false });
|
||||
const companyIdForProbe = companyIdForSecrets
|
||||
?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null);
|
||||
if (!companyIdForSecrets) {
|
||||
const secretRefs = await collectEnvironmentSecretRefs({ db, environment });
|
||||
if (secretRefs.length > 0) {
|
||||
|
|
@ -880,9 +896,10 @@ export function environmentRoutes(
|
|||
}
|
||||
}
|
||||
const probe = await probeEnvironment(db, environment, {
|
||||
companyId: companyIdForSecrets,
|
||||
companyId: companyIdForProbe,
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
applyCustomImageTemplate: environment.driver === "sandbox",
|
||||
acquireSandboxRuntimeLease: environment.driver === "sandbox",
|
||||
});
|
||||
await logInstanceEnvironmentActivity({
|
||||
actor,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({
|
|||
.default("ubuntu:24.04"),
|
||||
reuseLease: z.boolean().optional().default(false),
|
||||
streamRunLogs: z.boolean().optional(),
|
||||
archiveOnRelease: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
const pluginSandboxProviderKeySchema = z.string()
|
||||
|
|
@ -91,6 +92,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({
|
|||
timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(),
|
||||
reuseLease: z.boolean().optional().default(false),
|
||||
streamRunLogs: z.boolean().optional(),
|
||||
archiveOnRelease: z.boolean().optional(),
|
||||
}).catchall(z.unknown());
|
||||
|
||||
const pluginEnvironmentConfigSchema = z.object({
|
||||
|
|
@ -633,6 +635,10 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
|||
environmentId,
|
||||
baseConfig: parsed.config,
|
||||
runtimeConfig,
|
||||
// Match the capture-time fingerprint exclusions: secret-ref paths
|
||||
// are excluded when the template's source fingerprint is computed,
|
||||
// so they must be excluded when re-checking it here.
|
||||
secretRefExcludePaths: collectSecretRefPaths(schema),
|
||||
})
|
||||
: runtimeConfig,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,11 +8,24 @@ import {
|
|||
type EnvironmentCustomImageTemplateKind,
|
||||
type SandboxEnvironmentConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { writeConfigValueAtPath } from "./json-schema-secret-refs.js";
|
||||
import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-secret-refs.js";
|
||||
|
||||
type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect;
|
||||
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding";
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [
|
||||
"timeoutMs",
|
||||
"reuseLease",
|
||||
"streamRunLogs",
|
||||
"archiveOnRelease",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"gpu",
|
||||
"autoStopInterval",
|
||||
"autoArchiveInterval",
|
||||
"autoDeleteInterval",
|
||||
];
|
||||
|
||||
export interface EnvironmentCustomImageRuntimeConfigBinding {
|
||||
field: string;
|
||||
|
|
@ -111,6 +124,101 @@ export function applyCustomImageTemplateToSandboxConfig(
|
|||
return next as SandboxEnvironmentConfig;
|
||||
}
|
||||
|
||||
export function environmentCustomImageTemplateMatchesBaseConfig(input: {
|
||||
template: EnvironmentCustomImageTemplate;
|
||||
baseConfig: SandboxEnvironmentConfig;
|
||||
secretRefExcludePaths?: Iterable<string>;
|
||||
}): boolean {
|
||||
const expectedFingerprint = input.template.sourceEnvironmentConfigFingerprint;
|
||||
if (!expectedFingerprint) return true;
|
||||
// Capture-time fingerprints exclude both runtime-only fields and the
|
||||
// provider's secret-ref paths (see finishSetupSession); the runtime match
|
||||
// must exclude the same set or configs that carry a secret ref can never
|
||||
// match and the active template gets silently dropped.
|
||||
const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])];
|
||||
const normalizedFingerprint = fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, {
|
||||
excludePaths: [
|
||||
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
...secretRefExcludePaths,
|
||||
],
|
||||
});
|
||||
if (normalizedFingerprint === expectedFingerprint) return true;
|
||||
// Backward compatibility for templates captured before runtime-only fields
|
||||
// were excluded from the source fingerprint (secret-ref paths have always
|
||||
// been excluded at capture time).
|
||||
return fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, {
|
||||
excludePaths: secretRefExcludePaths,
|
||||
}) === expectedFingerprint;
|
||||
}
|
||||
|
||||
// Standard boot-source fields shared across sandbox providers. A change to any
|
||||
// of these means the user asked for a different base, so a captured template
|
||||
// no longer reflects the saved config and cannot simply be re-linked.
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS = [
|
||||
"snapshot",
|
||||
"image",
|
||||
"template",
|
||||
] as const;
|
||||
|
||||
export type EnvironmentCustomImageConfigChangeKind = "none" | "relinkable" | "breaking";
|
||||
|
||||
/**
|
||||
* Classifies a saved-config change relative to an active captured template.
|
||||
*
|
||||
* - `none`: the template either already matched the new config, or was already
|
||||
* detached before this change; nothing to reconcile.
|
||||
* - `relinkable`: only fields that cannot affect the captured template's
|
||||
* contents or reachability changed (for example a region hint), so the
|
||||
* template's source fingerprint can be re-stamped to the new config.
|
||||
* - `breaking`: a boot-source field or a provider-declared template identity
|
||||
* path changed; the captured template no longer corresponds to the config
|
||||
* and a fresh capture is required.
|
||||
*/
|
||||
export function classifyEnvironmentCustomImageConfigChange(input: {
|
||||
template: EnvironmentCustomImageTemplate;
|
||||
previousConfig: SandboxEnvironmentConfig;
|
||||
nextConfig: SandboxEnvironmentConfig;
|
||||
secretRefExcludePaths?: Iterable<string>;
|
||||
templateIdentityPaths?: Iterable<string>;
|
||||
}): EnvironmentCustomImageConfigChangeKind {
|
||||
const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])];
|
||||
if (!environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template: input.template,
|
||||
baseConfig: input.previousConfig,
|
||||
secretRefExcludePaths,
|
||||
})) {
|
||||
return "none";
|
||||
}
|
||||
if (environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template: input.template,
|
||||
baseConfig: input.nextConfig,
|
||||
secretRefExcludePaths,
|
||||
})) {
|
||||
return "none";
|
||||
}
|
||||
const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({
|
||||
templateKind: input.template.templateKind,
|
||||
metadata: input.template.metadata,
|
||||
});
|
||||
const breakingPaths = new Set<string>([
|
||||
"provider",
|
||||
binding.field,
|
||||
...binding.unsetFields,
|
||||
...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS,
|
||||
...(input.templateIdentityPaths ?? []),
|
||||
]);
|
||||
const previous = input.previousConfig as Record<string, unknown>;
|
||||
const next = input.nextConfig as Record<string, unknown>;
|
||||
for (const path of breakingPaths) {
|
||||
const before = readConfigValueAtPath(previous, path);
|
||||
const after = readConfigValueAtPath(next, path);
|
||||
if (stableStringify(before ?? null) !== stableStringify(after ?? null)) {
|
||||
return "breaking";
|
||||
}
|
||||
}
|
||||
return "relinkable";
|
||||
}
|
||||
|
||||
export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
|
|
@ -138,6 +246,7 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
|||
environmentId: string;
|
||||
baseConfig: SandboxEnvironmentConfig;
|
||||
runtimeConfig: SandboxEnvironmentConfig;
|
||||
secretRefExcludePaths?: Iterable<string>;
|
||||
now?: Date;
|
||||
},
|
||||
): Promise<SandboxEnvironmentConfig> {
|
||||
|
|
@ -155,15 +264,19 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
|
|||
|
||||
const active = environmentCustomImageTemplateFromRow(row);
|
||||
if (!active.templateRef) return input.runtimeConfig;
|
||||
if (!environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template: active,
|
||||
baseConfig: input.baseConfig,
|
||||
secretRefExcludePaths: input.secretRefExcludePaths,
|
||||
})) {
|
||||
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.
|
||||
// applied whenever the image/template-defining parts of the base config still
|
||||
// match. Runtime-only knobs such as lease reuse, timeouts, and resource hints
|
||||
// are excluded from the fingerprint so those edits do not discard the capture.
|
||||
const now = input.now ?? new Date();
|
||||
await db
|
||||
.update(environmentCustomImageTemplates)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ import {
|
|||
} from "./plugin-environment-driver.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import {
|
||||
ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
classifyEnvironmentCustomImageConfigChange,
|
||||
fingerprintEnvironmentSandboxProviderConfig,
|
||||
ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY,
|
||||
defaultEnvironmentCustomImageRuntimeConfigBinding,
|
||||
environmentCustomImageTemplateMatchesBaseConfig,
|
||||
normalizeEnvironmentCustomImageRuntimeConfigBinding,
|
||||
environmentCustomImageTemplateFromRow,
|
||||
readEnvironmentCustomImageTemplateKind as readTemplateKind,
|
||||
|
|
@ -57,10 +60,22 @@ type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect;
|
|||
|
||||
export interface EnvironmentCustomImageOverview {
|
||||
activeTemplate: EnvironmentCustomImageTemplate | null;
|
||||
/**
|
||||
* Whether the active template's captured fingerprint still matches the
|
||||
* environment's saved config. `false` means runs silently fall back to the
|
||||
* base image until the template is re-captured. `null` when unknown (no
|
||||
* active template, or the config could not be evaluated).
|
||||
*/
|
||||
activeTemplateMatchesConfig: boolean | null;
|
||||
activeSession: EnvironmentCustomImageSetupSession | null;
|
||||
latestSession: EnvironmentCustomImageSetupSession | null;
|
||||
}
|
||||
|
||||
export type EnvironmentCustomImageReconciliation =
|
||||
| { action: "none" }
|
||||
| { action: "relinked"; template: EnvironmentCustomImageTemplate }
|
||||
| { action: "detached"; template: EnvironmentCustomImageTemplate };
|
||||
|
||||
export interface EnvironmentCustomImageSetupSessionResult {
|
||||
session: EnvironmentCustomImageSetupSession;
|
||||
connectionPayload: PluginEnvironmentInteractiveSetupConnectionPayload | null;
|
||||
|
|
@ -595,17 +610,44 @@ export function environmentCustomImageService(
|
|||
}
|
||||
}
|
||||
|
||||
async function templateMatchesEnvironmentConfig(
|
||||
environment: Environment,
|
||||
template: EnvironmentCustomImageTemplate,
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
if (parsed.driver !== "sandbox") return false;
|
||||
if (parsed.config.provider !== template.provider) return false;
|
||||
return environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig: parsed.config,
|
||||
secretRefExcludePaths: parsed.config.provider === "fake"
|
||||
? []
|
||||
: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getOverview: async (input: {
|
||||
environmentId: string;
|
||||
}): Promise<EnvironmentCustomImageOverview> => {
|
||||
await requireEnvironment(input.environmentId);
|
||||
const environment = await requireEnvironment(input.environmentId);
|
||||
const [activeTemplate, activeSession, latestSession] = await Promise.all([
|
||||
resolveActiveTemplate(db, input),
|
||||
getActiveSetupSession(input),
|
||||
getLatestSetupSession(input),
|
||||
]);
|
||||
return { activeTemplate, activeSession, latestSession };
|
||||
return {
|
||||
activeTemplate,
|
||||
activeTemplateMatchesConfig: activeTemplate
|
||||
? await templateMatchesEnvironmentConfig(environment, activeTemplate)
|
||||
: null,
|
||||
activeSession,
|
||||
latestSession,
|
||||
};
|
||||
},
|
||||
|
||||
getActiveTemplate: async (input: {
|
||||
|
|
@ -763,7 +805,10 @@ export function environmentCustomImageService(
|
|||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
const baseFingerprint = parsed.driver === "sandbox"
|
||||
? fingerprintEnvironmentSandboxProviderConfig(parsed.config, {
|
||||
excludePaths: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider),
|
||||
excludePaths: [
|
||||
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider),
|
||||
],
|
||||
})
|
||||
: null;
|
||||
const provider = await resolveSetupProvider({
|
||||
|
|
@ -876,6 +921,84 @@ export function environmentCustomImageService(
|
|||
return await cancelSession(session, input.reason ?? "cancelled", "cancelled");
|
||||
},
|
||||
|
||||
/**
|
||||
* Keeps the active captured template consistent with a just-saved config
|
||||
* change. Changes that cannot affect the captured contents (for example a
|
||||
* region hint) re-stamp the template's source fingerprint so it keeps
|
||||
* applying; boot-source or provider-identity changes report `detached` so
|
||||
* callers can tell the user a fresh capture is required. Never throws for
|
||||
* unparseable configs; the save itself must not fail on reconciliation.
|
||||
*/
|
||||
reconcileActiveTemplateForConfigChange: async (input: {
|
||||
environmentId: string;
|
||||
previous: Pick<Environment, "driver" | "config">;
|
||||
next: Pick<Environment, "driver" | "config">;
|
||||
now?: Date;
|
||||
}): Promise<EnvironmentCustomImageReconciliation> => {
|
||||
let previousParsed;
|
||||
let nextParsed;
|
||||
try {
|
||||
previousParsed = parseEnvironmentDriverConfig(input.previous);
|
||||
nextParsed = parseEnvironmentDriverConfig(input.next);
|
||||
} catch {
|
||||
return { action: "none" };
|
||||
}
|
||||
if (previousParsed.driver !== "sandbox") return { action: "none" };
|
||||
const template = await resolveActiveTemplate(db, {
|
||||
environmentId: input.environmentId,
|
||||
provider: previousParsed.config.provider,
|
||||
});
|
||||
if (!template?.templateRef) return { action: "none" };
|
||||
const secretRefExcludePaths = previousParsed.config.provider === "fake"
|
||||
? []
|
||||
: [...await resolveSandboxProviderSecretRefPaths(db, previousParsed.config.provider)];
|
||||
if (!environmentCustomImageTemplateMatchesBaseConfig({
|
||||
template,
|
||||
baseConfig: previousParsed.config,
|
||||
secretRefExcludePaths,
|
||||
})) {
|
||||
// Already detached before this save; leave it alone.
|
||||
return { action: "none" };
|
||||
}
|
||||
if (nextParsed.driver !== "sandbox" || nextParsed.config.provider !== template.provider) {
|
||||
return { action: "detached", template };
|
||||
}
|
||||
const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({
|
||||
db,
|
||||
driverKey: template.provider,
|
||||
});
|
||||
if (!resolvedDriver) {
|
||||
// Without driver metadata the change cannot be classified safely.
|
||||
return { action: "detached", template };
|
||||
}
|
||||
const changeKind = classifyEnvironmentCustomImageConfigChange({
|
||||
template,
|
||||
previousConfig: previousParsed.config,
|
||||
nextConfig: nextParsed.config,
|
||||
secretRefExcludePaths,
|
||||
templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [],
|
||||
});
|
||||
if (changeKind === "none") return { action: "none" };
|
||||
if (changeKind === "breaking") return { action: "detached", template };
|
||||
const now = input.now ?? new Date();
|
||||
const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(nextParsed.config, {
|
||||
excludePaths: [
|
||||
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
|
||||
...secretRefExcludePaths,
|
||||
],
|
||||
});
|
||||
const row = await db
|
||||
.update(environmentCustomImageTemplates)
|
||||
.set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now })
|
||||
.where(eq(environmentCustomImageTemplates.id, template.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return {
|
||||
action: "relinked",
|
||||
template: row ? environmentCustomImageTemplateFromRow(row) : template,
|
||||
};
|
||||
},
|
||||
|
||||
rollbackTemplate: async (input: {
|
||||
environmentId: string;
|
||||
now?: Date;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import os from "node:os";
|
|||
import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js";
|
||||
import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
|
||||
export async function probeEnvironment(
|
||||
db: Db,
|
||||
|
|
@ -19,11 +20,14 @@ export async function probeEnvironment(
|
|||
pluginWorkerManager?: PluginWorkerManager;
|
||||
resolvedConfig?: ParsedEnvironmentConfig;
|
||||
applyCustomImageTemplate?: boolean;
|
||||
acquireSandboxRuntimeLease?: boolean;
|
||||
} = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const resolvedCompanyId = options.companyId ?? null;
|
||||
const parsed = options.resolvedConfig ?? (
|
||||
resolvedCompanyId || options.applyCustomImageTemplate === true
|
||||
options.acquireSandboxRuntimeLease === true
|
||||
? parseEnvironmentDriverConfig(environment)
|
||||
: resolvedCompanyId || options.applyCustomImageTemplate === true
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, {
|
||||
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
|
||||
})
|
||||
|
|
@ -43,6 +47,98 @@ export async function probeEnvironment(
|
|||
}
|
||||
|
||||
if (parsed.driver === "sandbox") {
|
||||
if (options.acquireSandboxRuntimeLease) {
|
||||
if (!resolvedCompanyId) {
|
||||
return {
|
||||
ok: false,
|
||||
driver: "sandbox",
|
||||
summary: "Sandbox environment probe requires a companyId context.",
|
||||
details: {
|
||||
provider: parsed.config.provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = environmentRuntimeService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const probeEnvironmentConfig = {
|
||||
...environment,
|
||||
config: {
|
||||
...(environment.config ?? {}),
|
||||
// Test probes should prove a fresh provider boot, not resume a retained
|
||||
// agent lease and report success without provider-side activity.
|
||||
reuseLease: false,
|
||||
// Keep the probe sandbox inspectable in the provider dashboard
|
||||
// (archived, provider-side expiry) instead of deleting it the moment
|
||||
// the probe finishes.
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
};
|
||||
let leaseRecord: Awaited<ReturnType<typeof runtime.acquireRunLease>> | null = null;
|
||||
let releaseStatus: "released" | "failed" = "released";
|
||||
try {
|
||||
leaseRecord = await runtime.acquireRunLease({
|
||||
companyId: resolvedCompanyId,
|
||||
environment: probeEnvironmentConfig,
|
||||
issueId: null,
|
||||
agentId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
adapterType: null,
|
||||
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
|
||||
});
|
||||
const metadata = leaseRecord.lease.metadata ?? {};
|
||||
const provider = leaseRecord.lease.provider ?? parsed.config.provider;
|
||||
const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0
|
||||
? metadata.sandboxName.trim()
|
||||
: null;
|
||||
return {
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: sandboxName
|
||||
? `Connected to ${provider} sandbox ${sandboxName}.`
|
||||
: `Connected to ${provider} sandbox environment.`,
|
||||
details: {
|
||||
provider,
|
||||
providerLeaseId: leaseRecord.lease.providerLeaseId,
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leasePolicy: leaseRecord.lease.leasePolicy,
|
||||
metadata,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
releaseStatus = "failed";
|
||||
return {
|
||||
ok: false,
|
||||
driver: "sandbox",
|
||||
summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`,
|
||||
details: {
|
||||
provider: parsed.config.provider,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (leaseRecord) {
|
||||
const driver = runtime.getDriver(environment.driver);
|
||||
try {
|
||||
await driver?.releaseRunLease({
|
||||
environment: probeEnvironmentConfig,
|
||||
lease: leaseRecord.lease,
|
||||
status: releaseStatus,
|
||||
});
|
||||
} catch (releaseError) {
|
||||
// Cleanup failures must not mask the connection result shown to
|
||||
// the operator, but a leaked sandbox should still be traceable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
|
||||
if (!options.pluginWorkerManager) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,23 @@ import { api } from "./client";
|
|||
|
||||
export interface EnvironmentCustomImageOverview {
|
||||
activeTemplate: EnvironmentCustomImageTemplate | null;
|
||||
/**
|
||||
* `false` means the environment config changed since capture and runs fall
|
||||
* back to the base image until a new image is captured. `null` when unknown.
|
||||
*/
|
||||
activeTemplateMatchesConfig?: boolean | null;
|
||||
activeSession: EnvironmentCustomImageSetupSession | null;
|
||||
latestSession: EnvironmentCustomImageSetupSession | null;
|
||||
}
|
||||
|
||||
export type EnvironmentCustomImageReconciliation =
|
||||
| { action: "relinked"; template: EnvironmentCustomImageTemplate }
|
||||
| { action: "detached"; template: EnvironmentCustomImageTemplate };
|
||||
|
||||
export type EnvironmentUpdateResult = Environment & {
|
||||
customImageReconciliation?: EnvironmentCustomImageReconciliation;
|
||||
};
|
||||
|
||||
export interface EnvironmentCustomImageConnectionPayload {
|
||||
type: string;
|
||||
command?: string | null;
|
||||
|
|
@ -64,8 +77,14 @@ export const environmentsApi = {
|
|||
status?: "active" | "archived";
|
||||
config?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) => api.patch<Environment>(`/environments/${environmentId}`, body),
|
||||
probe: (environmentId: string) => api.post<EnvironmentProbeResult>(`/environments/${environmentId}/probe`, {}),
|
||||
}) => api.patch<EnvironmentUpdateResult>(`/environments/${environmentId}`, body),
|
||||
probe: (environmentId: string, companyId?: string | null) =>
|
||||
api.post<EnvironmentProbeResult>(
|
||||
companyId
|
||||
? `/environments/${environmentId}/probe?${customImageCompanyQuery(companyId)}`
|
||||
: `/environments/${environmentId}/probe`,
|
||||
{},
|
||||
),
|
||||
probeConfig: (companyId: string, body: {
|
||||
name?: string;
|
||||
driver: "local" | "ssh" | "sandbox" | "plugin";
|
||||
|
|
|
|||
|
|
@ -485,7 +485,96 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
expect(buttonsAfter[0].disabled).toBe(true);
|
||||
expect(buttonsAfter[1].textContent?.trim()).toBe("Test provider");
|
||||
expect(buttonsAfter[1].disabled).toBe(false);
|
||||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1");
|
||||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
|
||||
});
|
||||
|
||||
it("explains that successful sandbox provider tests use a temporary sandbox", 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(supportedDaytonaCapabilities());
|
||||
mockEnvironmentsApi.probe.mockResolvedValue({
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: "Connected to Daytona sandbox paperclip-probe.",
|
||||
details: {
|
||||
provider: "daytona",
|
||||
diagnostics: [],
|
||||
metadata: {
|
||||
provider: "daytona",
|
||||
sandboxId: "473167E9",
|
||||
sandboxName: "paperclip-probe",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
|
||||
expect(container.textContent).toContain("Connected to Daytona sandbox paperclip-probe.");
|
||||
expect(container.textContent).not.toContain("Verified temporary daytona sandbox");
|
||||
expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check");
|
||||
expect(container.textContent).not.toContain("provider dashboard");
|
||||
});
|
||||
|
||||
it("does not show sandbox lifecycle success copy for failed sandbox provider tests", 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(supportedDaytonaCapabilities());
|
||||
mockEnvironmentsApi.probe.mockResolvedValue({
|
||||
ok: false,
|
||||
driver: "sandbox",
|
||||
summary: "Daytona sandbox probe failed.",
|
||||
details: {
|
||||
provider: "daytona",
|
||||
error: "Sandbox image was not found.",
|
||||
metadata: {
|
||||
provider: "daytona",
|
||||
sandboxId: "473167E9",
|
||||
sandboxName: "paperclip-probe",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Daytona sandbox probe failed.");
|
||||
expect(container.textContent).toContain("Sandbox image was not found.");
|
||||
expect(container.textContent).not.toContain("Verified temporary daytona sandbox");
|
||||
expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check");
|
||||
});
|
||||
|
||||
it("keeps the second environment's testing state when an earlier probe settles", async () => {
|
||||
|
|
@ -985,6 +1074,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
it("shows active template controls for refresh, rollback, and disable", async () => {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const activeTemplateId = "12345678-90ab-cdef-1234-567890abcdef";
|
||||
mockEnvironmentsApi.list.mockResolvedValue([
|
||||
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
|
||||
]);
|
||||
|
|
@ -1007,7 +1097,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
},
|
||||
});
|
||||
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
|
||||
activeTemplate: createTemplate({ id: "template-active" }),
|
||||
activeTemplate: createTemplate({ id: activeTemplateId }),
|
||||
activeSession: null,
|
||||
latestSession: null,
|
||||
});
|
||||
|
|
@ -1027,6 +1117,13 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
await waitForAssertion(() => {
|
||||
const dialog = getOpenDialog();
|
||||
expect(dialog?.textContent).toContain("Active template");
|
||||
expect(dialog?.textContent).toContain("redacted-template-ref");
|
||||
expect(dialog?.textContent).not.toContain("id 12345678-90a");
|
||||
expect(
|
||||
dialog?.querySelector(
|
||||
"[title='Provider snapshot ref redacted-template-ref (Paperclip template 12345678-90ab-cdef-1234-567890abcdef)']",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(findButton(dialog!, "Refresh")).toBeTruthy();
|
||||
expect(findButton(dialog!, "Rollback")).toBeTruthy();
|
||||
expect(findButton(dialog!, "Disable")).toBeTruthy();
|
||||
|
|
@ -1038,7 +1135,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith(
|
||||
"env-1",
|
||||
"company-1",
|
||||
{ templateId: "template-active" },
|
||||
{ templateId: activeTemplateId },
|
||||
);
|
||||
await waitForAssertion(() => {
|
||||
expect(getOpenDialog()?.textContent).toContain("Browser terminal");
|
||||
|
|
@ -1048,6 +1145,130 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows stale capturing setup sessions to be cancelled back to active template controls", async () => {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const activeTemplate = createTemplate({ id: "template-active" });
|
||||
let activeSession: ReturnType<typeof createSession> | null = createSession({ status: "capturing" });
|
||||
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(supportedDaytonaCapabilities());
|
||||
mockEnvironmentsApi.customImageTemplate.mockImplementation(async () => ({
|
||||
activeTemplate,
|
||||
activeSession,
|
||||
latestSession,
|
||||
}));
|
||||
mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({
|
||||
session: activeSession,
|
||||
connectionPayload: null,
|
||||
});
|
||||
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(() => {
|
||||
const dialog = getOpenDialog()!;
|
||||
expect(dialog.textContent).toContain("Capturing template");
|
||||
expect(dialog.textContent).toContain("Capture is in progress.");
|
||||
expect(findButton(dialog, "Finished")?.disabled).toBe(true);
|
||||
expect(findButton(dialog, "Cancel")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => click(findButton(getOpenDialog()!, "Cancel")));
|
||||
await waitForAssertion(() => {
|
||||
const dialog = getOpenDialog()!;
|
||||
expect(dialog.textContent).toContain("Active template");
|
||||
expect(findButton(dialog, "Refresh")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith(
|
||||
"session-1",
|
||||
{ reason: "operator cancelled" },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an out-of-sync warning when the active template no longer matches the saved config", 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(supportedDaytonaCapabilities());
|
||||
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
|
||||
activeTemplate: createTemplate({ id: "template-active" }),
|
||||
activeTemplateMatchesConfig: false,
|
||||
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(dialog.textContent).toContain("Not in use — the environment configuration changed");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the out-of-sync warning when the active template matches the saved config", 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(supportedDaytonaCapabilities());
|
||||
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
|
||||
activeTemplate: createTemplate({ id: "template-active" }),
|
||||
activeTemplateMatchesConfig: true,
|
||||
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(dialog.textContent).not.toContain("Not in use");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes company context when rolling back and disabling an active template", async () => {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
environmentsApi,
|
||||
type EnvironmentCustomImageConnectionPayload,
|
||||
type EnvironmentCustomImageSetupSessionResult,
|
||||
type EnvironmentUpdateResult,
|
||||
} from "@/api/environments";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { secretsApi } from "@/api/secrets";
|
||||
|
|
@ -205,6 +206,12 @@ function formatDateTime(value: string | Date | null | undefined): string | null
|
|||
return Number.isNaN(date.getTime()) ? null : date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatShortId(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= 12) return normalized;
|
||||
return `${normalized.slice(0, 12)}…`;
|
||||
}
|
||||
|
||||
function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null {
|
||||
return typeof payload?.command === "string" && payload.command.trim().length > 0
|
||||
? payload.command
|
||||
|
|
@ -928,13 +935,18 @@ function EnvironmentImageTemplatePanel({
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => cancelSetupMutation.mutate(session.id)}
|
||||
disabled={isMutating || isCapturing}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isCapturing ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Capture is in progress. If this state remains after a refresh or interrupted request, cancel it to return to the active template controls.
|
||||
</div>
|
||||
) : null}
|
||||
{session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? (
|
||||
<EnvironmentCustomImageBrowserTerminal autoConnect sessionId={session.id} />
|
||||
) : null}
|
||||
|
|
@ -961,6 +973,8 @@ function EnvironmentImageTemplatePanel({
|
|||
}
|
||||
|
||||
if (activeTemplate) {
|
||||
const templateRef = activeTemplate.templateRef?.trim() || null;
|
||||
const templateOutOfSync = overview?.activeTemplateMatchesConfig === false;
|
||||
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">
|
||||
|
|
@ -968,9 +982,28 @@ function EnvironmentImageTemplatePanel({
|
|||
<div className="text-xs font-medium">Active template</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{providerDisplayName} · {activeTemplate.templateKind}
|
||||
{" · "}
|
||||
<span
|
||||
className="break-all font-mono text-foreground"
|
||||
title={templateRef
|
||||
? `Provider ${activeTemplate.templateKind} ref ${templateRef} (Paperclip template ${activeTemplate.id})`
|
||||
: activeTemplate.id}
|
||||
>
|
||||
{templateRef ?? `id ${formatShortId(activeTemplate.id)}`}
|
||||
</span>
|
||||
{capturedAt ? ` · captured ${capturedAt}` : ""}
|
||||
{lastUsedAt ? ` · last used ${lastUsedAt}` : ""}
|
||||
</div>
|
||||
{templateOutOfSync ? (
|
||||
<div
|
||||
className="text-xs text-destructive"
|
||||
data-testid={`custom-image-template-out-of-sync-${environment.id}`}
|
||||
>
|
||||
Not in use — the environment configuration changed since this image was
|
||||
captured. Runs fall back to the base configuration until you capture a new
|
||||
image.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -1109,6 +1142,9 @@ export function CompanyEnvironments() {
|
|||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.environments.list(selectedCompanyId!),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.environments.customImageTemplate(environment.id),
|
||||
});
|
||||
setEnvironmentDialogOpen(false);
|
||||
setEditingEnvironmentId(null);
|
||||
setEnvironmentForm(createEmptyEnvironmentForm());
|
||||
|
|
@ -1119,6 +1155,20 @@ export function CompanyEnvironments() {
|
|||
body: `${environment.name} is ready.`,
|
||||
tone: "success",
|
||||
});
|
||||
const reconciliation = (environment as EnvironmentUpdateResult).customImageReconciliation;
|
||||
if (reconciliation?.action === "relinked") {
|
||||
pushToast({
|
||||
title: "Custom image kept active",
|
||||
body: "The captured image was re-linked to the updated configuration automatically.",
|
||||
tone: "info",
|
||||
});
|
||||
} else if (reconciliation?.action === "detached") {
|
||||
pushToast({
|
||||
title: "Custom image no longer applies",
|
||||
body: "This change alters what the captured image was built from. Runs use the base configuration until you capture a new image.",
|
||||
tone: "warn",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
|
|
@ -1150,7 +1200,7 @@ export function CompanyEnvironments() {
|
|||
});
|
||||
|
||||
const environmentProbeMutation = useMutation({
|
||||
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
|
||||
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId, selectedCompanyId),
|
||||
onMutate: (environmentId) => {
|
||||
setTestingEnvironmentId(environmentId);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue