mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its instance-level environments from the `PAPERCLIP_MANAGED_CONFIG` document > - #19490 added the plugin environment rows, their marker index and write floor, but nothing creates them yet > - The managed config still accepts only one sandbox entry, so a managed instance cannot get a plugin-provided environment without manual setup > - This pull request lets the managed config declare plugin entries beside the sandbox entry, and provisions one locked row for each at boot > - The benefit is that a managed instance gets its plugin environments with no manual setup, and the rows recover by themselves when their plugin restarts ## Linked Issues or Issue Description Refs #19490 (the plugin environment rows this pull request provisions) **Subsystem affected** Server environments: the managed-config parser, the managed environment bootstrap, and the environment routes' write floor. **Problem or motivation** A managed instance cannot get a plugin environment from its managed config. The `environments` section accepts one entry, and that entry always provisions the managed sandbox. Company settings cannot create a plugin environment either. So each managed instance that needs one requires manual setup. **Proposed solution** Let an `environments` entry declare `driver: "plugin"`. Boot provisions one locked row for each plugin entry through the row layer from #19490. A later pull request adds a one-time instance default for one plugin entry, and advertises the new managed-config capability. **Alternatives considered** Manual setup on each instance, and a new control-plane endpoint. Both need a step outside the deploy. The managed config already delivers the sandbox entry. **Roadmap alignment** This extends the existing managed-config `environments` section, which already provisions the managed sandbox. It adds no new product surface. ## What Changed - `server/src/services/managed-config.ts`: an `environments` entry may set `driver` to `"sandbox"` or `"plugin"`. An entry without `driver` is still the sandbox entry, so today's documents parse unchanged. - A plugin entry has `name`, optional `description`, `pluginKey` (the plugin's manifest id), `driverKey` and optional `driverConfig`. - At most one sandbox entry. Names are unique across the section, and `pluginKey`–`driverKey` pairs are unique. Secret-like keys in `driverConfig` refuse startup, as for sandbox `config`. An unknown key or driver still refuses startup, and that includes `instanceDefault` until the follow-up pull request. - Plugin entries parse into a new `pluginEnvironments` list. The `environments` list keeps only the sandbox entry, so its consumers do not change. - `MANAGED_ENVIRONMENT_DRIVERS` lists the accepted drivers. - `server/src/services/managed-environments.ts` (`applyManagedEnvironments`): - Boot refuses to start unless an auto-installed catalog entry installs each plugin entry's `pluginKey`. `plugins.autoInstall` holds catalog keys, not manifest ids, so the parser cannot check this. Boot uses the manifest ids `createApp` already resolves from the bundled and distribution catalogs (`app.locals.managedBundledPluginKeys`, passed in by `server/src/index.ts`). Without that list it resolves the catalog itself. - Plugin entries run after the sandbox entry and reuse its readiness handling. Boot awaits `pluginsReady`, then requires the plugin `ready`, declaring the entry's driver, and running a worker. Otherwise boot archives the row and re-ensures it on the worker's `ready`. - Recovery re-ensures on every worker `ready` until one ensure succeeds or the plugin rejects the config (HTTP 422). - If the ensure fails because the worker died, boot archives the row and waits for `ready`. If the worker is still running (for example the call timed out), boot keeps the row as it is, retries once at once and on the next `ready`, and otherwise at the next boot. No timer is added. - Removing an entry never archives its row. - A plugin-only document does not conflict with `PAPERCLIP_EXECUTION_MODE`, because plugin rows do not use the sandbox slot. - Plugin entries never change the instance default. The sandbox stamp still fills only an unset or local default, so it never replaces a plugin default. - `server/src/routes/environments.ts`: `isPlatformSlotEnvironment` treats a marked plugin row as a live slot while the document declares its entry. So the marker-clear escape hatch is refused then, and allowed again after the entry leaves the document. - `doc/DEVELOPING.md`: new "Managed environments" section. ## Verification - `cd server && npx vitest run src/__tests__/managed-config.test.ts`: old documents parse unchanged. Sandbox plus plugin parses. Each of these refuses startup: two sandbox entries, `driver: null`, duplicate names, a duplicate pair, a secret-like key, an unknown key (including `instanceDefault`), an unknown driver, a malformed `driverKey` or `driverConfig`. - `npx vitest run src/services/managed-environments-plugin-entries.test.ts` (the sandbox-entry tests stay in `managed-environments.test.ts`; shared fixtures are in `managed-environments.test-support.ts`): - A plugin entry is ensured with the validator's worker manager, after the sandbox entry. - Boot refuses a plugin entry whose `pluginKey` no auto-installed catalog entry installs, both from the passed list and from its own catalog lookup. - A plugin that is down archives the row, and worker recovery re-ensures it. A missing plugin, a plugin without the driver, or a worker manager without `call` counts as unavailable. - Recovery stays armed after a failed attempt and stops after one success or a permanent config rejection (422). A `ready` during an attempt queues one more. - A worker that dies during the boot ensure archives the row and arms recovery. A failure while the worker keeps running leaves the row as it is and retries. - Removing an entry does not archive. Plugin entries and the sandbox stamp never change a plugin default. - An ensure failure is counted, not thrown. - `npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres, real `applyManagedEnvironments`): the first boot creates the row and the second changes nothing. A plugin that is down archives the row, and recovery brings it back. Removing the entry does not archive the row. The instance default stays unset. The row tests from #19490 still pass. - `npx vitest run src/__tests__/environment-routes.test.ts`: the marker clear is refused while the document declares the entry, and allowed after it leaves. - `npx vitest run src/__tests__/instance-settings-service.test.ts src/__tests__/instance-settings-cloud-defaults.test.ts`: pass with the new `pluginEnvironments` field. - `pnpm --filter @paperclipai/server typecheck` passes. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. ## Risks - No schema change. - A managed-config document with a plugin entry now provisions a row at boot. A document without one behaves as before. - Adoption renames an unmarked row for the same plugin and driver to the declared name and resets its driver config to the declared one. The row keeps its id, agents and other metadata, and it is then locked like the managed sandbox. - Boot now refuses to start when a plugin entry names a plugin that no auto-installed catalog entry provides. This is the same fail-closed posture as an unknown sandbox provider. > 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 - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (file edits, shell, test runs). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { applyManagedEnvironments } from "./managed-environments.js";
|
|
import {
|
|
environmentRow,
|
|
environmentsSeam,
|
|
instanceSettingsSeam,
|
|
noDb,
|
|
parsedConfig,
|
|
readyDriverResolver,
|
|
reconciliationResult,
|
|
recoveryHandle,
|
|
runningWorkerManager,
|
|
tick,
|
|
type InstanceSettingsSeam,
|
|
} from "./managed-environments.test-support.js";
|
|
|
|
describe("applyManagedEnvironments", () => {
|
|
it("no-ops for self-hosted instances and for documents without environments", async () => {
|
|
expect(await applyManagedEnvironments(noDb, null)).toBeNull();
|
|
expect(await applyManagedEnvironments(noDb, parsedConfig())).toBeNull();
|
|
});
|
|
|
|
it("refuses startup when a forced execution mode also claims the managed sandbox slot", async () => {
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
await expect(
|
|
applyManagedEnvironments(noDb, config, {
|
|
env: { PAPERCLIP_EXECUTION_MODE: "kubernetes" },
|
|
}),
|
|
).rejects.toThrow(/mutually exclusive/);
|
|
});
|
|
|
|
it("ensures each declared environment through the provider-agnostic service call", async () => {
|
|
const ensureManagedSandboxEnvironment = vi
|
|
.fn()
|
|
.mockResolvedValue(reconciliationResult());
|
|
const config = parsedConfig({
|
|
environments: [
|
|
{
|
|
name: "Daytona",
|
|
description: "Managed Daytona sandbox.",
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
},
|
|
],
|
|
});
|
|
|
|
const resolveSandboxProviderDriver = readyDriverResolver();
|
|
const workerManager = runningWorkerManager();
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
instanceSettings: instanceSettingsSeam(),
|
|
environments: environmentsSeam({ ensureManagedSandboxEnvironment }),
|
|
resolveSandboxProviderDriver,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 1, failed: 0, added: 1, skipped: 0 });
|
|
expect(result).toMatchObject({ removed: 0, backedUp: 0 });
|
|
expect(resolveSandboxProviderDriver).toHaveBeenCalledWith({ db: noDb, driverKey: "daytona" });
|
|
expect(workerManager.isRunning).toHaveBeenCalledWith("plugin-1");
|
|
expect(ensureManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
expect(ensureManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
name: "Daytona",
|
|
description: "Managed Daytona sandbox.",
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
stockVersion: "2026.720.0",
|
|
platformFullyManaged: true,
|
|
});
|
|
// The frozen parsed config must not leak into the service (the row's
|
|
// config is mutated downstream when the provider key is forced in).
|
|
const passedConfig = ensureManagedSandboxEnvironment.mock.calls[0]?.[0]?.config;
|
|
expect(Object.isFrozen(passedConfig)).toBe(false);
|
|
});
|
|
|
|
it("waits for the bundled-plugin startup pass before ensuring anything", async () => {
|
|
const ensureManagedSandboxEnvironment = vi
|
|
.fn()
|
|
.mockResolvedValue(reconciliationResult());
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
let releasePlugins!: () => void;
|
|
const pluginsReady = new Promise<void>((resolve) => {
|
|
releasePlugins = resolve;
|
|
});
|
|
|
|
const pending = applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
pluginsReady,
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings: instanceSettingsSeam(),
|
|
environments: environmentsSeam({ ensureManagedSandboxEnvironment }),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
// Give the ensure every chance to (incorrectly) run early.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
|
|
releasePlugins();
|
|
expect(await pending).toMatchObject({ ensured: 1, failed: 0, added: 1 });
|
|
expect(ensureManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports operator-modified rows as skipped with a stock update still available", async () => {
|
|
const environments = environmentsSeam({
|
|
ensureManagedSandboxEnvironment: vi.fn().mockResolvedValue(reconciliationResult({
|
|
action: "skipped",
|
|
stockStatus: "operator_modified",
|
|
updateAvailable: true,
|
|
})),
|
|
});
|
|
const result = await applyManagedEnvironments(noDb, parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
}), {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ensured: 0,
|
|
failed: 0,
|
|
added: 0,
|
|
updated: 0,
|
|
unchanged: 0,
|
|
skipped: 1,
|
|
outcomes: [{
|
|
action: "skipped",
|
|
stockStatus: "operator_modified",
|
|
updateAvailable: true,
|
|
}],
|
|
});
|
|
});
|
|
|
|
it("skips an entry whose provider plugin is missing and archives its stale row", async () => {
|
|
const environments = environmentsSeam({
|
|
archiveManagedSandboxEnvironment: vi.fn().mockResolvedValue(environmentRow({ status: "archived" })),
|
|
});
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
environments,
|
|
resolveSandboxProviderDriver: vi.fn(async () => null),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
expect(environments.archiveManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
provider: "daytona",
|
|
});
|
|
});
|
|
|
|
it("skips (and counts failed) an entry whose provider plugin is not ready", async () => {
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver("disabled"),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
expect(environments.archiveManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
provider: "daytona",
|
|
});
|
|
});
|
|
|
|
it("skips an entry whose plugin record is ready but whose worker is not running", async () => {
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const workerManager = runningWorkerManager(false);
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(workerManager.isRunning).toHaveBeenCalledWith("plugin-1");
|
|
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
expect(environments.archiveManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
provider: "daytona",
|
|
});
|
|
// No registered handle → no respawn is coming; degraded until next boot.
|
|
expect(workerManager.getWorker).toHaveBeenCalledWith("plugin-1");
|
|
});
|
|
|
|
it("reactivates the archived environment once when the provider worker recovers", async () => {
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
environments: [
|
|
{ name: "Daytona", provider: "daytona", config: { target: "us" } },
|
|
],
|
|
});
|
|
|
|
const { handle, fireReady } = recoveryHandle();
|
|
const workerManager = {
|
|
isRunning: vi.fn(() => false),
|
|
getWorker: vi.fn(() => handle),
|
|
};
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
// The boot pass itself stays degraded: archived, counted failed.
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.archiveManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
provider: "daytona",
|
|
});
|
|
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
|
|
// Worker manager respawns the worker → the handle re-emits `ready`.
|
|
fireReady();
|
|
fireReady();
|
|
await tick();
|
|
|
|
expect(environments.ensureManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
expect(environments.ensureManagedSandboxEnvironment).toHaveBeenCalledWith({
|
|
name: "Daytona",
|
|
description: undefined,
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
stockVersion: "2026.720.0",
|
|
platformFullyManaged: true,
|
|
});
|
|
expect(handle.off).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-checks isRunning after subscribing so a recovery during the archive is not missed", async () => {
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const { handle } = recoveryHandle();
|
|
// Down at the gate check, already back up by the time the listener is
|
|
// registered — the `ready` event has been missed.
|
|
const workerManager = {
|
|
isRunning: vi
|
|
.fn()
|
|
.mockReturnValueOnce(false)
|
|
.mockReturnValue(true),
|
|
getWorker: vi.fn(() => handle),
|
|
};
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
await tick();
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.ensureManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not subscribe for recovery when the plugin record is missing or not ready", async () => {
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
for (const resolveSandboxProviderDriver of [
|
|
vi.fn(async () => null),
|
|
readyDriverResolver("disabled"),
|
|
]) {
|
|
const workerManager = runningWorkerManager(false);
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
instanceSettings: instanceSettingsSeam(),
|
|
environments: environmentsSeam(),
|
|
resolveSandboxProviderDriver,
|
|
});
|
|
expect(workerManager.getWorker).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("logs, not throws, when the recovery re-ensure fails", async () => {
|
|
const environments = environmentsSeam({
|
|
ensureManagedSandboxEnvironment: vi.fn().mockRejectedValue(new Error("db exploded")),
|
|
});
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const { handle, fireReady } = recoveryHandle();
|
|
const workerManager = {
|
|
isRunning: vi.fn(() => false),
|
|
getWorker: vi.fn(() => handle),
|
|
};
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager,
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
fireReady();
|
|
await tick();
|
|
// The rejection is swallowed into a log line; reaching this point without
|
|
// an unhandled rejection is the assertion.
|
|
expect(environments.ensureManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fails closed when no worker manager is provided", async () => {
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("counts a skipped entry once even when archiving its stale row fails", async () => {
|
|
const environments = environmentsSeam({
|
|
archiveManagedSandboxEnvironment: vi.fn().mockRejectedValue(new Error("db exploded")),
|
|
});
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
environments,
|
|
resolveSandboxProviderDriver: vi.fn(async () => null),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.archiveManagedSandboxEnvironment).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is fail-safe per entry: an ensure failure is counted, not thrown", async () => {
|
|
const environments = environmentsSeam({
|
|
ensureManagedSandboxEnvironment: vi.fn().mockRejectedValue(new Error("db exploded")),
|
|
});
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
const result = await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
|
expect(environments.archiveManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("points an unset instance default at the managed sandbox environment", async () => {
|
|
const instanceSettings = instanceSettingsSeam();
|
|
const config = parsedConfig({
|
|
features: { enableManagedSandboxOnly: true },
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings,
|
|
environments: environmentsSeam(),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(instanceSettings.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" });
|
|
});
|
|
|
|
it("records the stamp marker on the managed row so a revert is attributable", async () => {
|
|
const instanceSettings = instanceSettingsSeam();
|
|
const environments = environmentsSeam();
|
|
const config = parsedConfig({
|
|
features: { enableManagedSandboxOnly: true },
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings,
|
|
environments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(environments.update).toHaveBeenCalledWith("env-1", {
|
|
metadata: expect.objectContaining({ managedDefaultStamped: true }),
|
|
});
|
|
});
|
|
|
|
it("moves a local (or dangling) instance default onto the managed sandbox environment", async () => {
|
|
const localDefault = instanceSettingsSeam({
|
|
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-local" }) as InstanceSettingsSeam["get"],
|
|
});
|
|
const config = parsedConfig({
|
|
features: { enableManagedSandboxOnly: true },
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings: localDefault,
|
|
environments: environmentsSeam({
|
|
getById: vi.fn().mockResolvedValue(environmentRow({ id: "env-local", driver: "local" })),
|
|
}),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
expect(localDefault.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" });
|
|
|
|
// A default pointing at a deleted row is repaired the same way.
|
|
const danglingDefault = instanceSettingsSeam({
|
|
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-gone" }) as InstanceSettingsSeam["get"],
|
|
});
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings: danglingDefault,
|
|
environments: environmentsSeam({ getById: vi.fn().mockResolvedValue(null) }),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
expect(danglingDefault.update).toHaveBeenCalledWith({ defaultEnvironmentId: "env-1" });
|
|
});
|
|
|
|
it("leaves the instance default alone while managed-sandbox-only is not declared", async () => {
|
|
// Without the mode, local execution is a legitimate default; provisioning
|
|
// the managed environment must not silently move runs into the sandbox.
|
|
const instanceSettings = instanceSettingsSeam();
|
|
const config = parsedConfig({
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings,
|
|
environments: environmentsSeam(),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(instanceSettings.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reverts a stamped managed default when managed-sandbox-only turns off", async () => {
|
|
// Turning the mode off must not keep routing default-following runs
|
|
// into the sandbox off the stale stamp — even when the document no
|
|
// longer declares any environments or the row was archived, which
|
|
// skip per-entry reconciliation entirely. Only a default pointing at
|
|
// the managed row AND carrying the reconciliation stamp reverts.
|
|
const stampedRow = environmentRow({
|
|
status: "archived",
|
|
metadata: { managedByPaperclip: true, managedDefaultStamped: true },
|
|
});
|
|
const stamped = instanceSettingsSeam({
|
|
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["get"],
|
|
});
|
|
const stampedEnvironments = environmentsSeam({
|
|
findManagedSandboxEnvironment: vi.fn().mockResolvedValue(stampedRow),
|
|
});
|
|
// No environments declaration at all: the cleanup must still run.
|
|
const config = parsedConfig({});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings: stamped,
|
|
environments: stampedEnvironments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
expect(stampedEnvironments.findManagedSandboxEnvironment).toHaveBeenCalledWith(undefined, {
|
|
includeArchived: true,
|
|
});
|
|
expect(stamped.update).toHaveBeenCalledWith({ defaultEnvironmentId: null });
|
|
// The marker clears with the revert so a later deliberate tenant
|
|
// selection of the managed row is never mistaken for a stamp.
|
|
expect(stampedEnvironments.update).toHaveBeenCalledWith("env-1", {
|
|
metadata: { managedByPaperclip: true },
|
|
});
|
|
|
|
// The tenant's own deliberate managed-row default (no stamp marker)
|
|
// survives the mode turning off.
|
|
const tenantManagedDefault = instanceSettingsSeam({
|
|
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["get"],
|
|
});
|
|
const unmarkedEnvironments = environmentsSeam({
|
|
findManagedSandboxEnvironment: vi.fn().mockResolvedValue(
|
|
environmentRow({ metadata: { managedByPaperclip: true } }),
|
|
),
|
|
});
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings: tenantManagedDefault,
|
|
environments: unmarkedEnvironments,
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
expect(tenantManagedDefault.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never overrides a tenant-chosen custom default environment", async () => {
|
|
const instanceSettings = instanceSettingsSeam({
|
|
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-ssh" }) as InstanceSettingsSeam["get"],
|
|
});
|
|
const config = parsedConfig({
|
|
features: { enableManagedSandboxOnly: true },
|
|
environments: [{ name: "Daytona", provider: "daytona" }],
|
|
});
|
|
|
|
await applyManagedEnvironments(noDb, config, {
|
|
env: {},
|
|
workerManager: runningWorkerManager(),
|
|
instanceSettings,
|
|
environments: environmentsSeam({
|
|
getById: vi.fn().mockResolvedValue(environmentRow({ id: "env-ssh", driver: "ssh" })),
|
|
}),
|
|
resolveSandboxProviderDriver: readyDriverResolver(),
|
|
});
|
|
|
|
expect(instanceSettings.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|