mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(environments): fill the managed instance default once, under a lock (#19632)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - On a managed instance under `enableManagedSandboxOnly`, boot points the instance default at the managed sandbox when nothing deliberate is set > - That write read the default and wrote it later without a lock. Two overlapping boots, or a boot and an admin write, could overwrite a value the other had just committed > - A revert path also reset that stamped default whenever the mode was turned off, so a platform-filled default could change again later > - A planned plugin-environment default should fill only an unset default and never change a set one, which needs every instance's default to stay set > - This pull request makes the fill one locked transaction and removes the revert, so a filled default is an ordinary set value > - The benefit is a single, race-free rule: boot fills an unset default once, and nothing changes a set default afterwards ## Linked Issues or Issue Description Refs #19551 (the plugin-environment default that builds on this rule) **Subsystem affected** Server environments: the managed environment bootstrap and the instance settings route. **Problem or motivation** `applyManagedEnvironments` read the instance default, decided, and wrote it in separate steps with no lock, so a concurrent boot or admin write could be overwritten. When `enableManagedSandboxOnly` was not declared, a revert reset a default the platform had filled. That needed a `managedDefaultStamped` marker, plus a marker clear on every admin default write. A later boot could therefore change a default that had already been set. **Proposed solution** Fill the default in one transaction that locks the instance default row and rechecks it. Fill only an unset, local or deleted default. Remove the revert and its marker, so a filled default behaves like any other set value. **Alternatives considered** Keeping the revert and adding locks to it. A revert on a mode change would still move an existing instance's default later, which a fill-only plugin default cannot tolerate. **Roadmap alignment** This narrows existing managed-environment behaviour. It adds no product surface. ## What Changed - New `server/src/services/managed-instance-default.ts`: `fillUnsetInstanceDefault(db, environmentId)`. In one transaction it creates the settings row if needed, locks the instance default row (`FOR UPDATE`), and rechecks the current value. It writes only when the default is unset, names the local environment, or names a deleted environment. Any other set default is kept. - `server/src/services/managed-environments.ts`: - Under `enableManagedSandboxOnly`, the managed sandbox fill now uses that helper, through a new `fillInstanceDefault` test seam. - The mode-off revert and the `managedDefaultStamped` marker are removed. - The unused `instanceSettings` seam option is removed, along with the `getById`, `update` and `findManagedSandboxEnvironment` methods of the environments seam. - `server/src/routes/instance-settings.ts`: an explicit default write is a plain `update` again. The marker clear and its transaction are gone. - `doc/DEVELOPING.md`: the "Managed environments" section states the fill rule. Existing rows may still carry `managedDefaultStamped` in their metadata. Nothing reads it any more, and it is not part of the managed stock hash. ## Verification - `cd server && npx vitest run src/__tests__/managed-instance-default.test.ts` (embedded Postgres): - The fill writes an unset default, including on a fresh instance with no settings row. It also writes a default that names the local environment, or one whose environment was deleted. - It never changes a default set to an ssh, plugin or other sandbox environment, and leaves a default that already names the target alone. - Controlled overlap: an uncommitted admin default write makes the fill wait, and the fill then keeps the admin's value. This test fails without the row lock. - Two overlapping fills: exactly one writes. - Real boot pass: the default is filled under managed-sandbox-only and stays the same after a boot with the mode off. A set default stays on every boot. - `npx vitest run src/services/managed-environments.test.ts src/services/managed-environments-plugin-entries.test.ts`: the fill runs only under managed-sandbox-only, with the managed sandbox row. It never runs for a plugin environment. Nothing is written when the mode is off. A failed fill does not fail the ensure. - `npx vitest run src/__tests__/instance-settings-routes.test.ts`: an explicit default write is a plain update that touches no environment row. - `npx vitest run src/__tests__/managed-plugin-environments.test.ts src/__tests__/environment-routes.test.ts src/__tests__/server-startup-feedback-export.test.ts src/__tests__/instance-settings-service.test.ts`: pass. - `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 - Behaviour change: when `enableManagedSandboxOnly` is turned off for an instance, a default the platform filled with the managed sandbox now stays. Local execution is still allowed then, but it is no longer the default unless an admin picks it. - No schema change. Stale `managedDefaultStamped` keys stay in some row metadata and are ignored. - The fill holds the instance default row lock for one short transaction at boot. > 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 - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8621bcf53e
commit
913102870d
9 files changed
+294
-331
No files matched your search
@@ -2054,6 +2054,14 @@ unmarked row for the same plugin and driver. While the plugin is down, the row i
|
||||
archived; it comes back when the worker is ready. Removing an entry never archives
|
||||
its row. Plugin entries do not change the instance default.
|
||||
|
||||
Under `enableManagedSandboxOnly`, boot fills the instance default with the
|
||||
managed sandbox only when the default is unset, names the local environment, or
|
||||
names a deleted one. The fill locks the default row and rechecks it, so a
|
||||
concurrent boot or admin write is never overwritten. A filled default is an
|
||||
ordinary set value: nothing reverts it when the mode is later turned off, and an
|
||||
admin can change it in instance settings.
|
||||
|
||||
|
||||
### Experimental native remote workspace storage
|
||||
|
||||
`experimental.remoteWorkspaceStorageEnabled` is a managed feature, on by default
|
||||
|
||||
@@ -357,10 +357,9 @@ describe("instance settings routes", () => {
|
||||
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.update).toHaveBeenCalledWith(
|
||||
{ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" },
|
||||
{ db: TX_SENTINEL },
|
||||
);
|
||||
expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({
|
||||
defaultEnvironmentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -376,19 +375,9 @@ describe("instance settings routes", () => {
|
||||
expect(mockInstanceSettingsService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the managed-default stamp marker on an explicit tenant default write", async () => {
|
||||
// A tenant write of defaultEnvironmentId reclassifies the default as
|
||||
// tenant-chosen: the reconciliation stamp marker on the managed
|
||||
// sandbox row must not survive, or a later managed-sandbox-only
|
||||
// mode-off pass would mistake the tenant's choice for a stamp.
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue({
|
||||
id: "managed-env-1",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: { managedByPaperclip: true, managedDefaultStamped: true },
|
||||
});
|
||||
it("writes an explicit default as a plain set value, touching no environment row", async () => {
|
||||
// Boot only fills an unset, local or dangling default, so an admin's
|
||||
// choice needs no marker and no sibling write to stay theirs.
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const patchRes = await request(app)
|
||||
@@ -396,42 +385,10 @@ describe("instance settings routes", () => {
|
||||
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockEnvironmentService.update).toHaveBeenCalledWith(
|
||||
"managed-env-1",
|
||||
{ metadata: { managedByPaperclip: true } },
|
||||
{ db: TX_SENTINEL },
|
||||
);
|
||||
// Both writes commit in ONE transaction — each receives the SAME tx —
|
||||
// so no partial failure can desync the stamp marker from the default
|
||||
// (neither a stale stamp on a tenant choice, nor a reconciliation
|
||||
// default that lost its marker and can never revert).
|
||||
expect(mockInstanceSettingsService.update).toHaveBeenCalledWith(
|
||||
{ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" },
|
||||
{ db: TX_SENTINEL },
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts the whole request (no committed settings) when the stamp-marker clear fails inside the transaction", async () => {
|
||||
// The marker clear and the settings write share a transaction, so a
|
||||
// failure in either rolls the whole thing back — a real DB would
|
||||
// discard both; here the settings write is never even reached.
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue({
|
||||
id: "managed-env-1",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: { managedByPaperclip: true, managedDefaultStamped: true },
|
||||
expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({
|
||||
defaultEnvironmentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
mockEnvironmentService.update.mockRejectedValue(new Error("metadata write failed"));
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings")
|
||||
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
||||
|
||||
expect(patchRes.status).toBeGreaterThanOrEqual(500);
|
||||
expect(mockInstanceSettingsService.update).not.toHaveBeenCalled();
|
||||
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unknown defaultEnvironmentId values with 422", async () => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, environments, instanceSettings, type Db } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { fillUnsetInstanceDefault } from "../services/managed-instance-default.js";
|
||||
import { applyManagedEnvironments } from "../services/managed-environments.js";
|
||||
import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "../services/managed-config.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("managed instance default (locked fill)", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let sandboxId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-managed-instance-default-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(environments);
|
||||
const [sandbox] = await db.insert(environments).values({
|
||||
name: "Managed Sandbox",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "daytona" },
|
||||
metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" },
|
||||
}).returning();
|
||||
sandboxId = sandbox!.id;
|
||||
});
|
||||
|
||||
const instanceDefault = async () =>
|
||||
(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")))[0]?.defaultEnvironmentId ?? null;
|
||||
const setInstanceDefault = async (environmentId: string | null) => {
|
||||
const [row] = await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default"));
|
||||
if (row) await db.update(instanceSettings).set({ defaultEnvironmentId: environmentId }).where(eq(instanceSettings.id, row.id));
|
||||
else await db.insert(instanceSettings).values({ singletonKey: "default", defaultEnvironmentId: environmentId });
|
||||
};
|
||||
const environment = async (name: string, driver: string) => {
|
||||
const [row] = await db.insert(environments).values({ name, driver, status: "active", config: {} }).returning();
|
||||
return row!.id;
|
||||
};
|
||||
|
||||
it("fills an unset default, including on a fresh instance with no settings row", async () => {
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: null });
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("fills a default that names the local environment", async () => {
|
||||
const localId = await environment("Local", "local");
|
||||
await setInstanceDefault(localId);
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: localId });
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("fills a default whose environment was deleted (the reference is cleared)", async () => {
|
||||
const goneId = await environment("Gone", "ssh");
|
||||
await setInstanceDefault(goneId);
|
||||
await db.delete(environments).where(eq(environments.id, goneId));
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toMatchObject({ filled: true });
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("never changes a set default, whatever chose it", async () => {
|
||||
for (const driver of ["ssh", "plugin", "sandbox"]) {
|
||||
const chosenId = await environment(`Chosen ${driver}`, driver);
|
||||
await setInstanceDefault(chosenId);
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: false, previousDefaultEnvironmentId: chosenId });
|
||||
expect(await instanceDefault()).toBe(chosenId);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a default that already names the target alone", async () => {
|
||||
await setInstanceDefault(sandboxId);
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: false, previousDefaultEnvironmentId: sandboxId });
|
||||
});
|
||||
|
||||
it("waits for an overlapping default write and then keeps it (controlled overlap)", async () => {
|
||||
const chosenId = await environment("Admin choice", "ssh");
|
||||
await setInstanceDefault(null);
|
||||
// An admin's default write holds the row while the fill starts.
|
||||
let releaseWrite!: () => void;
|
||||
const writeHeld = new Promise<void>((resolve) => { releaseWrite = resolve; });
|
||||
let locked!: () => void;
|
||||
const rowLocked = new Promise<void>((resolve) => { locked = resolve; });
|
||||
const adminWrite = db.transaction(async (tx) => {
|
||||
await tx.update(instanceSettings).set({ defaultEnvironmentId: chosenId }).where(eq(instanceSettings.singletonKey, "default"));
|
||||
locked();
|
||||
await writeHeld;
|
||||
});
|
||||
await rowLocked;
|
||||
|
||||
let settled = false;
|
||||
const fill = fillUnsetInstanceDefault(db, sandboxId).then((result) => { settled = true; return result; });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
// An unlocked read-then-write would already have filled the stale "unset".
|
||||
expect(settled).toBe(false);
|
||||
|
||||
releaseWrite();
|
||||
await adminWrite;
|
||||
expect(await fill).toEqual({ filled: false, previousDefaultEnvironmentId: chosenId });
|
||||
expect(await instanceDefault()).toBe(chosenId);
|
||||
});
|
||||
|
||||
it("lets only one of two overlapping boots fill", async () => {
|
||||
const results = await Promise.all([fillUnsetInstanceDefault(db, sandboxId), fillUnsetInstanceDefault(db, sandboxId)]);
|
||||
expect(results.filter((result) => result.filled)).toHaveLength(1);
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
describe("boot (applyManagedEnvironments)", () => {
|
||||
const managedConfig = (features: Record<string, unknown>) => parseManagedConfigEnv({
|
||||
[MANAGED_CONFIG_ENV_KEY]: JSON.stringify({
|
||||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.1106.0",
|
||||
features,
|
||||
plugins: { autoInstall: ["daytona"] },
|
||||
environments: [{ name: "Managed Sandbox", provider: "daytona" }],
|
||||
}),
|
||||
});
|
||||
const boot = async (features: Record<string, unknown>) => {
|
||||
const [row] = await db.select().from(environments).where(eq(environments.id, sandboxId));
|
||||
return applyManagedEnvironments(db, managedConfig(features), {
|
||||
env: {},
|
||||
workerManager: { isRunning: () => true, getWorker: () => undefined },
|
||||
environments: {
|
||||
ensureManagedSandboxEnvironment: vi.fn(async () => ({
|
||||
environment: row as never,
|
||||
action: "unchanged" as const,
|
||||
stockStatus: "stock_current" as const,
|
||||
updateAvailable: false,
|
||||
stockHash: "sha256:stock",
|
||||
})),
|
||||
archiveManagedSandboxEnvironment: vi.fn(async () => null),
|
||||
ensureManagedPluginEnvironment: vi.fn(),
|
||||
archiveManagedPluginEnvironment: vi.fn(async () => null),
|
||||
},
|
||||
resolveSandboxProviderDriver: async () => ({ plugin: { id: "daytona-plugin", pluginKey: "daytona", status: "ready" } }),
|
||||
});
|
||||
};
|
||||
|
||||
it("fills the default under managed-sandbox-only, and never reverts it when the mode is off", async () => {
|
||||
await boot({ enableManagedSandboxOnly: true });
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
|
||||
await boot({});
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("keeps a set default on every boot", async () => {
|
||||
const chosenId = await environment("Admin choice", "ssh");
|
||||
await setInstanceDefault(chosenId);
|
||||
await boot({ enableManagedSandboxOnly: true });
|
||||
await boot({ enableManagedSandboxOnly: true });
|
||||
expect(await instanceDefault()).toBe(chosenId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -162,26 +162,10 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag
|
||||
await assertAgentsReadyForEnvironmentChange(db, await environmentById(previousId), await environmentById(req.body.defaultEnvironmentId),
|
||||
{ inheritsDefault: true });
|
||||
}
|
||||
// An explicit tenant write of the instance default reclassifies its
|
||||
// attribution: whatever the default becomes — including a deliberate
|
||||
// re-selection of the managed sandbox row — it is tenant-chosen, so
|
||||
// the reconciliation stamp marker must not survive to let a later
|
||||
// managed-sandbox-only mode-off pass mistake the tenant's choice for a
|
||||
// stamp and revert it. The marker clear and the settings write commit
|
||||
// in ONE transaction, so no partial failure can desync attribution
|
||||
// from the default (neither a stale stamp on a tenant choice, nor a
|
||||
// reconciliation default that lost its marker and can never revert).
|
||||
const writesDefault = Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId");
|
||||
const managedSandbox = writesDefault
|
||||
? await environments.findManagedSandboxEnvironment(undefined, { includeArchived: true })
|
||||
: null;
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (managedSandbox?.metadata?.managedDefaultStamped === true) {
|
||||
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedSandbox.metadata;
|
||||
await environments.update(managedSandbox.id, { metadata: remainingMetadata }, { db: tx });
|
||||
}
|
||||
return svc.update(req.body, { db: tx });
|
||||
});
|
||||
// An explicit default write is an ordinary set value: boot only fills an
|
||||
// unset, local or dangling default (`fillUnsetInstanceDefault`), and the
|
||||
// lock it takes on this row means it never overwrites a concurrent write.
|
||||
const updated = await svc.update(req.body);
|
||||
const actor = getActorInfo(req);
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
await Promise.all(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { idleWorkSnapshot } from "./task-admission.js";
|
||||
import {
|
||||
PLUGIN_KEY,
|
||||
environmentsSeam,
|
||||
instanceSettingsSeam,
|
||||
noDb,
|
||||
parsedConfig,
|
||||
pluginEnvironmentRow,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
reconciliationResult,
|
||||
recoveryHandle,
|
||||
tick,
|
||||
type InstanceSettingsSeam,
|
||||
type RecoveryHandle,
|
||||
} from "./managed-environments.test-support.js";
|
||||
|
||||
@@ -48,7 +46,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -88,7 +85,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
@@ -102,7 +98,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: ["paperclip.daytona-sandbox-provider"],
|
||||
})).rejects.toThrow(/uses plugin "example.remote-host-plugin", which no "plugins.autoInstall" catalog entry installs/);
|
||||
@@ -116,7 +111,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: { PAPERCLIP_BUNDLED_PLUGIN_ROOT: "/nonexistent-paperclip-catalog" },
|
||||
workerManager: workerManager(),
|
||||
environments: environmentsSeam(),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
})).rejects.toThrow(/auto-install key "remote-host" is not in the bundled catalog/);
|
||||
});
|
||||
|
||||
@@ -125,7 +119,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: { PAPERCLIP_EXECUTION_MODE: "kubernetes" },
|
||||
workerManager: workerManager(),
|
||||
environments: environmentsSeam(),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
})).resolves.toMatchObject({ ensured: 1 });
|
||||
@@ -141,7 +134,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -170,7 +162,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -202,7 +193,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments: environmentsSeam({ ensureManagedPluginEnvironment }),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -226,7 +216,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments: environmentsSeam({ ensureManagedPluginEnvironment }),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -254,7 +243,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -282,7 +270,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: workerManager(true, handle),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -308,7 +295,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -335,7 +321,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
@@ -363,7 +348,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments: environmentsSeam({ ensureManagedPluginEnvironment: ensureManagedPluginEnvironment as never }),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
recovery,
|
||||
@@ -400,8 +384,7 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin,
|
||||
resolveEnvironmentPlugin,
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
@@ -417,28 +400,28 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
})).toBeNull();
|
||||
expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never changes the instance default, and the sandbox stamp never replaces a plugin default", async () => {
|
||||
const settings = instanceSettingsSeam({
|
||||
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "plugin-env" }) as InstanceSettingsSeam["get"],
|
||||
});
|
||||
it("never fills the instance default with a plugin environment", async () => {
|
||||
// Only the managed sandbox fills an unset default, through the locked
|
||||
// fill, which keeps any set default (a plugin environment included).
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: false, previousDefaultEnvironmentId: "plugin-env" }));
|
||||
await applyManagedEnvironments(noDb, configWith(
|
||||
[{ name: "Daytona", provider: "daytona" }, pluginEntry()],
|
||||
{ enableManagedSandboxOnly: true },
|
||||
), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
instanceSettings: settings,
|
||||
environments: environmentsSeam({ getById: vi.fn().mockResolvedValue(pluginEnvironmentRow()) }),
|
||||
fillInstanceDefault,
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(settings.update).not.toHaveBeenCalled();
|
||||
expect(fillInstanceDefault).toHaveBeenCalledTimes(1);
|
||||
expect(fillInstanceDefault).toHaveBeenCalledWith("env-1");
|
||||
});
|
||||
|
||||
it("is fail-safe: an ensure failure is logged and counted, not thrown", async () => {
|
||||
@@ -448,7 +431,6 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
environments: environmentsSeam({
|
||||
ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(new Error("validator refused")),
|
||||
}),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
|
||||
@@ -94,7 +94,6 @@ export function tick() {
|
||||
}
|
||||
|
||||
export type EnvironmentsSeam = NonNullable<ApplyManagedEnvironmentsOptions["environments"]>;
|
||||
export type InstanceSettingsSeam = NonNullable<ApplyManagedEnvironmentsOptions["instanceSettings"]>;
|
||||
|
||||
export function environmentsSeam(overrides: Partial<EnvironmentsSeam> = {}): EnvironmentsSeam {
|
||||
return {
|
||||
@@ -102,10 +101,6 @@ export function environmentsSeam(overrides: Partial<EnvironmentsSeam> = {}): Env
|
||||
overrides.ensureManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(reconciliationResult()),
|
||||
archiveManagedSandboxEnvironment:
|
||||
overrides.archiveManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
getById: overrides.getById ?? vi.fn().mockResolvedValue(null),
|
||||
update: overrides.update ?? vi.fn().mockResolvedValue(environmentRow()),
|
||||
findManagedSandboxEnvironment:
|
||||
overrides.findManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
ensureManagedPluginEnvironment:
|
||||
overrides.ensureManagedPluginEnvironment
|
||||
?? vi.fn().mockResolvedValue({ environment: pluginEnvironmentRow(), action: "added" }),
|
||||
@@ -127,13 +122,3 @@ export function pluginEnvironmentRow(overrides: Record<string, unknown> = {}) {
|
||||
}) as never;
|
||||
}
|
||||
|
||||
export function instanceSettingsSeam(overrides: Partial<InstanceSettingsSeam> = {}): InstanceSettingsSeam {
|
||||
return {
|
||||
get:
|
||||
overrides.get ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: null }) as InstanceSettingsSeam["get"]),
|
||||
update:
|
||||
overrides.update ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["update"]),
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { applyManagedEnvironments } from "./managed-environments.js";
|
||||
import {
|
||||
environmentRow,
|
||||
environmentsSeam,
|
||||
instanceSettingsSeam,
|
||||
noDb,
|
||||
parsedConfig,
|
||||
readyDriverResolver,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
recoveryHandle,
|
||||
runningWorkerManager,
|
||||
tick,
|
||||
type InstanceSettingsSeam,
|
||||
} from "./managed-environments.test-support.js";
|
||||
|
||||
describe("applyManagedEnvironments", () => {
|
||||
@@ -51,7 +49,6 @@ describe("applyManagedEnvironments", () => {
|
||||
const result = await applyManagedEnvironments(noDb, config, {
|
||||
env: {},
|
||||
workerManager,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
environments: environmentsSeam({ ensureManagedSandboxEnvironment }),
|
||||
resolveSandboxProviderDriver,
|
||||
});
|
||||
@@ -92,7 +89,6 @@ describe("applyManagedEnvironments", () => {
|
||||
env: {},
|
||||
pluginsReady,
|
||||
workerManager: runningWorkerManager(),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
environments: environmentsSeam({ ensureManagedSandboxEnvironment }),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
});
|
||||
@@ -289,8 +285,7 @@ describe("applyManagedEnvironments", () => {
|
||||
await applyManagedEnvironments(noDb, config, {
|
||||
env: {},
|
||||
workerManager,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
environments: environmentsSeam(),
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver,
|
||||
});
|
||||
expect(workerManager.getWorker).not.toHaveBeenCalled();
|
||||
@@ -378,8 +373,8 @@ describe("applyManagedEnvironments", () => {
|
||||
expect(environments.archiveManagedSandboxEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("points an unset instance default at the managed sandbox environment", async () => {
|
||||
const instanceSettings = instanceSettingsSeam();
|
||||
it("fills the instance default with the managed sandbox under managed-sandbox-only", async () => {
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
const config = parsedConfig({
|
||||
features: { enableManagedSandboxOnly: true },
|
||||
environments: [{ name: "Daytona", provider: "daytona" }],
|
||||
@@ -388,73 +383,19 @@ describe("applyManagedEnvironments", () => {
|
||||
await applyManagedEnvironments(noDb, config, {
|
||||
env: {},
|
||||
workerManager: runningWorkerManager(),
|
||||
instanceSettings,
|
||||
fillInstanceDefault,
|
||||
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" });
|
||||
// The locked fill (`fillUnsetInstanceDefault`) decides whether to write.
|
||||
expect(fillInstanceDefault).toHaveBeenCalledWith("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 fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
const config = parsedConfig({
|
||||
environments: [{ name: "Daytona", provider: "daytona" }],
|
||||
});
|
||||
@@ -462,89 +403,40 @@ describe("applyManagedEnvironments", () => {
|
||||
await applyManagedEnvironments(noDb, config, {
|
||||
env: {},
|
||||
workerManager: runningWorkerManager(),
|
||||
instanceSettings,
|
||||
fillInstanceDefault,
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
});
|
||||
|
||||
expect(instanceSettings.update).not.toHaveBeenCalled();
|
||||
expect(fillInstanceDefault).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, {
|
||||
it("never reverts a filled default when managed-sandbox-only is not declared", async () => {
|
||||
// A filled default is an ordinary set value. With the mode off, and even
|
||||
// with no environments declared, boot writes nothing to the default.
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
const environments = environmentsSeam();
|
||||
expect(await applyManagedEnvironments(noDb, parsedConfig({}), {
|
||||
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();
|
||||
fillInstanceDefault,
|
||||
environments,
|
||||
})).toBeNull();
|
||||
expect(fillInstanceDefault).not.toHaveBeenCalled();
|
||||
expect(environments.ensureManagedSandboxEnvironment).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({
|
||||
it("keeps the environment ensured when filling the default fails", async () => {
|
||||
const result = await applyManagedEnvironments(noDb, 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" })),
|
||||
}),
|
||||
fillInstanceDefault: vi.fn().mockRejectedValue(new Error("db exploded")),
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
});
|
||||
|
||||
expect(instanceSettings.update).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ ensured: 1, failed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ import { HttpError } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { fillUnsetInstanceDefault, type FillInstanceDefaultResult } from "./managed-instance-default.js";
|
||||
import type { ManagedInstanceConfig } from "./managed-config.js";
|
||||
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
||||
@@ -143,14 +143,11 @@ export interface ApplyManagedEnvironmentsOptions {
|
||||
ReturnType<typeof environmentService>,
|
||||
| "ensureManagedSandboxEnvironment"
|
||||
| "archiveManagedSandboxEnvironment"
|
||||
| "getById"
|
||||
| "update"
|
||||
| "findManagedSandboxEnvironment"
|
||||
| "ensureManagedPluginEnvironment"
|
||||
| "archiveManagedPluginEnvironment"
|
||||
>;
|
||||
/** Test seam: overrides the instance-settings service built from `db`. */
|
||||
instanceSettings?: Pick<ReturnType<typeof instanceSettingsService>, "get" | "update">;
|
||||
/** Test seam: overrides the locked fill of an unset instance default (`fillUnsetInstanceDefault`). */
|
||||
fillInstanceDefault?: (environmentId: string) => Promise<FillInstanceDefaultResult>;
|
||||
/** Test seam: overrides the sandbox-provider plugin driver lookup. */
|
||||
resolveSandboxProviderDriver?: (input: {
|
||||
db: Db;
|
||||
@@ -249,44 +246,11 @@ export async function applyManagedEnvironments(
|
||||
): Promise<ApplyManagedEnvironmentsResult | null> {
|
||||
if (!managedConfig) return null;
|
||||
|
||||
const settings = opts.instanceSettings ?? instanceSettingsService(db);
|
||||
const fillInstanceDefault = opts.fillInstanceDefault
|
||||
?? ((environmentId: string) => fillUnsetInstanceDefault(db, environmentId));
|
||||
const environments = opts.environments ?? environmentService(db);
|
||||
const managedSandboxOnlyDeclared = managedConfig.features.enableManagedSandboxOnly === true;
|
||||
|
||||
// Mode-off default cleanup runs BEFORE any ensure, and regardless of
|
||||
// whether the document still declares environments or the provider is
|
||||
// available: a reconciliation-stamped default must not outlive the mode
|
||||
// through the paths that skip per-entry reconciliation entirely — the
|
||||
// declaration was removed, the provider is down, or the row was
|
||||
// archived. Only a default that points at the managed row AND carries
|
||||
// the `managedDefaultStamped` marker reverts; tenant-chosen defaults
|
||||
// (no marker) are untouched.
|
||||
if (!managedSandboxOnlyDeclared) {
|
||||
try {
|
||||
const managedRow = await environments.findManagedSandboxEnvironment(undefined, {
|
||||
includeArchived: true,
|
||||
});
|
||||
if (
|
||||
managedRow &&
|
||||
managedRow.metadata?.managedDefaultStamped === true &&
|
||||
((await settings.get()).defaultEnvironmentId ?? null) === managedRow.id
|
||||
) {
|
||||
await settings.update({ defaultEnvironmentId: null });
|
||||
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedRow.metadata ?? {};
|
||||
await environments.update(managedRow.id, { metadata: remainingMetadata });
|
||||
logger.info(
|
||||
{ environmentId: managedRow.id },
|
||||
"instance default environment reverted from the managed sandbox environment (managed-sandbox-only is not declared)",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
"failed to revert the stamped managed sandbox instance default (degraded: stale default until the next boot)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pluginSpecs = managedConfig.pluginEnvironments;
|
||||
if (managedConfig.environments.length === 0 && pluginSpecs.length === 0) return null;
|
||||
|
||||
@@ -330,53 +294,24 @@ export async function applyManagedEnvironments(
|
||||
const resolveDriver = opts.resolveSandboxProviderDriver ?? resolvePluginSandboxProviderDriverByKey;
|
||||
|
||||
/**
|
||||
* Point the instance default at the managed sandbox row when no
|
||||
* deliberate choice stands in the way: an unset default, a default on
|
||||
* the (hidden-under-this-mode) local row, or a dangling reference all
|
||||
* move to the managed environment, so pickers and run selection agree
|
||||
* that "the default" is the platform sandbox. A tenant-chosen custom
|
||||
* environment (ssh, their own sandbox) is never overridden.
|
||||
* Under `enableManagedSandboxOnly`, point the instance default at the
|
||||
* managed sandbox row when nothing deliberate is set: an unset default, the
|
||||
* (hidden-under-this-mode) local row, or a dangling reference. A set default
|
||||
* (any other environment, whoever chose it) is never changed, and nothing
|
||||
* reverts the filled default later: it is an ordinary set value from then
|
||||
* on. The write is one locked transaction (`fillUnsetInstanceDefault`), so
|
||||
* overlapping boots and admin writes cannot overwrite each other.
|
||||
*
|
||||
* Gated symmetrically on the document declaring
|
||||
* `enableManagedSandboxOnly`:
|
||||
*
|
||||
* - Declared: stamp the managed row as the default, so pickers and run
|
||||
* selection agree the platform sandbox is "the default". The stamp is
|
||||
* recorded as `managedDefaultStamped` in the row's platform-owned
|
||||
* metadata (which boot reconciliation preserves and the client write
|
||||
* floor protects), so a stamped default is distinguishable from one
|
||||
* the tenant chose deliberately.
|
||||
* - Not declared: local execution is a legitimate default again, so a
|
||||
* default THIS reconciliation stamped earlier (points at the managed
|
||||
* row AND carries the stamp marker) reverts to unset — otherwise
|
||||
* turning the mode off would keep routing default-following runs
|
||||
* into the sandbox off a stale stamp. A default the tenant selected
|
||||
* themselves — the managed row without the marker, or any custom
|
||||
* environment — is untouched in both directions.
|
||||
*
|
||||
* Idempotent and best-effort — a failure degrades to the run-time
|
||||
* policy, which refuses local under managed-sandbox-only regardless.
|
||||
* Best-effort: a failure degrades to the run-time policy, which refuses
|
||||
* local under managed-sandbox-only regardless.
|
||||
*/
|
||||
const ensureManagedInstanceDefault = async (managedEnvironment: {
|
||||
id: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}): Promise<void> => {
|
||||
const ensureManagedInstanceDefault = async (managedEnvironment: { id: string }): Promise<void> => {
|
||||
if (!managedSandboxOnlyDeclared) return;
|
||||
try {
|
||||
const current = (await settings.get()).defaultEnvironmentId ?? null;
|
||||
if (current === managedEnvironment.id) return;
|
||||
if (current !== null) {
|
||||
const currentEnvironment = await environments.getById(current);
|
||||
if (currentEnvironment && currentEnvironment.driver !== "local") return;
|
||||
}
|
||||
// Marker first: a crash between the two writes must never leave a
|
||||
// stamped default that the revert path cannot attribute.
|
||||
await environments.update(managedEnvironment.id, {
|
||||
metadata: { ...(managedEnvironment.metadata ?? {}), managedDefaultStamped: true },
|
||||
});
|
||||
await settings.update({ defaultEnvironmentId: managedEnvironment.id });
|
||||
const { filled, previousDefaultEnvironmentId } = await fillInstanceDefault(managedEnvironment.id);
|
||||
if (!filled) return;
|
||||
logger.info(
|
||||
{ environmentId: managedEnvironment.id, previousDefaultEnvironmentId: current },
|
||||
{ environmentId: managedEnvironment.id, previousDefaultEnvironmentId },
|
||||
"instance default environment set to the managed sandbox environment",
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The managed instance default: one locked "fill if unset" write.
|
||||
*
|
||||
* On a managed instance, boot points the instance default at the managed
|
||||
* sandbox when nothing deliberate is set: the default is unset, names the
|
||||
* local environment, or names an environment that no longer exists. A set
|
||||
* default (any other environment, whoever chose it) is never changed.
|
||||
*
|
||||
* The write locks the instance default row and rechecks it inside the same
|
||||
* transaction, so two overlapping boots, or a boot overlapping an admin's
|
||||
* default write, can never overwrite a value that another transaction just
|
||||
* committed: whichever commits first wins, and the other sees a set default.
|
||||
*
|
||||
* Once filled, the default is an ordinary set value. Nothing reverts it
|
||||
* automatically; an admin can change it in instance settings.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environments, instanceSettings } from "@paperclipai/db";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
/** Singleton key of the instance settings row that holds `defaultEnvironmentId`. */
|
||||
const INSTANCE_DEFAULT_SETTINGS_KEY = "default";
|
||||
|
||||
export interface FillInstanceDefaultResult {
|
||||
filled: boolean;
|
||||
previousDefaultEnvironmentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the instance default at `environmentId` only if the current default
|
||||
* is unset, the local environment, or an environment that no longer exists.
|
||||
*/
|
||||
export async function fillUnsetInstanceDefault(db: Db, environmentId: string): Promise<FillInstanceDefaultResult> {
|
||||
return await db.transaction(async (tx) => {
|
||||
const settings = instanceSettingsService(tx as unknown as Db);
|
||||
// Creates the singleton row on a fresh instance, so the lock below always
|
||||
// has a row to hold.
|
||||
await settings.get();
|
||||
const [defaultRow] = await tx
|
||||
.select({ defaultEnvironmentId: instanceSettings.defaultEnvironmentId })
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, INSTANCE_DEFAULT_SETTINGS_KEY))
|
||||
.for("update");
|
||||
const current = defaultRow?.defaultEnvironmentId ?? null;
|
||||
if (current === environmentId) return { filled: false, previousDefaultEnvironmentId: current };
|
||||
if (current !== null) {
|
||||
const [currentEnvironment] = await tx
|
||||
.select({ driver: environments.driver })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, current));
|
||||
if (currentEnvironment && currentEnvironment.driver !== "local") {
|
||||
return { filled: false, previousDefaultEnvironmentId: current };
|
||||
}
|
||||
}
|
||||
await settings.update({ defaultEnvironmentId: environmentId }, { db: tx });
|
||||
return { filled: true, previousDefaultEnvironmentId: current };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user