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:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-11-06 15:01:58 -08:00
1 parent 8621bcf53e
commit 913102870d
9 files changed
+294 -331

No files matched your search

+8
View File
@@ -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);
});
});
});
+4 -20
View File
@@ -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"]),
};
}
+26 -134
View File
@@ -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 });
});
});
+18 -83
View File
@@ -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 };
});
}