diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index ce8bba7d7e6..14a7cd31419 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2098,12 +2098,14 @@ sandbox entry (no `driver`, or `driver: "sandbox"`) and any number of "driver": "plugin", "pluginKey": "example.remote-host-plugin", "driverKey": "tasks", - "driverConfig": {} + "driverConfig": {}, + "instanceDefault": true } ``` -Names and `pluginKey`–`driverKey` pairs must be unique, and `driverConfig` must -not contain secret-like keys. Any other key refuses startup. `plugins.autoInstall` +Names and `pluginKey`–`driverKey` pairs must be unique, `driverConfig` must not +contain secret-like keys, and `instanceDefault` is allowed on at most one plugin +entry and never on the sandbox entry. Any other key refuses startup. `plugins.autoInstall` holds catalog keys, not manifest ids, so boot also refuses to start unless an auto-installed bundled or distribution catalog entry installs the entry's `pluginKey`. @@ -2112,15 +2114,28 @@ Boot validates the driver config through the plugin, marks the row `managedPluginEnvironment: ":"`, and adopts an existing unmarked row for the same plugin and driver. While the plugin is down, the row is archived; it comes back when the worker is ready. Removing an entry never archives -its row. Plugin entries do not change the instance default. +its row. -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 +Boot fills the instance default only when it is unset, names the local +environment, or names a deleted one. A plugin entry with `instanceDefault` fills +first, right after its ensure; then, under `enableManagedSandboxOnly`, the +managed sandbox fills. So a new instance gets the plugin environment, and an +existing instance keeps the default it already has. If the plugin is down at a +new instance's first boot, the managed sandbox fills the default instead, and the +plugin environment does not replace it later. Recovery after boot never fills. +Like an admin's default write, the plugin fill declines while an active agent +that follows the default has not been prepared by that plugin; the managed +sandbox then fills as usual. 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. +`server/src/services/managed-config-capabilities.json` lists the managed-config +capabilities a release supports, for the control plane to read at a release +commit. It must equal `MANAGED_CONFIG_CAPABILITIES` in `managed-config.ts` +(`managed-config-capabilities.test.ts` checks this), and the parser accepts plugin +entries only while that list includes `pluginEnvironments`. + ### Experimental native remote workspace storage diff --git a/server/src/__tests__/managed-config.test.ts b/server/src/__tests__/managed-config.test.ts index e160ff37f98..c8229ed578e 100644 --- a/server/src/__tests__/managed-config.test.ts +++ b/server/src/__tests__/managed-config.test.ts @@ -428,6 +428,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => { pluginKey: PLUGIN_KEY, driverKey: "tasks", driverConfig: {}, + instanceDefault: true, ...overrides, }); const sandbox = { name: "Managed Sandbox", provider: "daytona", config: { target: "us" } }; @@ -456,6 +457,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => { pluginKey: PLUGIN_KEY, driverKey: "tasks", driverConfig: {}, + instanceDefault: true, }]); expect(Object.isFrozen(config?.pluginEnvironments)).toBe(true); expect(Object.isFrozen(config?.pluginEnvironments[0])).toBe(true); @@ -464,7 +466,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => { it("parses a plugin entry alone, with optional fields omitted", () => { const config = parseManagedConfigEnv(docWith([{ name: "Remote", driver: "plugin", pluginKey: PLUGIN_KEY, driverKey: "tasks" }])); expect(config?.environments).toEqual([]); - expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {} }); + expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {}, instanceDefault: false }); expect(config?.pluginEnvironments[0]?.description).toBeUndefined(); }); @@ -478,10 +480,27 @@ describe("parseManagedConfigEnv plugin environment entries", () => { }); it("refuses a duplicate pluginKey and driverKey pair", () => { - expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second" })]))) + expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", instanceDefault: false })]))) .toThrow(/duplicates another plugin entry's pluginKey and driverKey/); }); + it("refuses instanceDefault on two entries", () => { + expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", driverKey: "other" })]))) + .toThrow(/at most one entry may become the instance default/); + }); + + it("refuses instanceDefault on the sandbox entry, even false", () => { + expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: true }]))) + .toThrow(/"environments\[0\].instanceDefault" is not allowed on the sandbox entry/); + expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }]))) + .toThrow(/not allowed on the sandbox entry/); + }); + + it("refuses a non-boolean instanceDefault", () => { + expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: "yes" })]))) + .toThrow(/"environments\[0\].instanceDefault" must be a boolean/); + }); + it("refuses secret-like driverConfig keys at any depth", () => { expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { apiToken: "t" } })]))) .toThrow(/"environments\[0\].driverConfig" key "apiToken" looks secret-bearing/); @@ -495,9 +514,6 @@ describe("parseManagedConfigEnv plugin environment entries", () => { expect(() => parseManagedConfigEnv(docWith([remoteHost({ envVars: {} })]))).toThrow(/unknown key "envVars"/); expect(() => parseManagedConfigEnv(docWith([remoteHost({ provider: "daytona" })]))).toThrow(/unknown key "provider"/); expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, pluginKey: PLUGIN_KEY }]))).toThrow(/unknown key "pluginKey"/); - // Not supported by this build on either entry. - expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: true })]))).toThrow(/unknown key "instanceDefault"/); - expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }]))).toThrow(/unknown key "instanceDefault"/); }); it("refuses an unknown driver and malformed plugin fields", () => { diff --git a/server/src/__tests__/managed-instance-default.test.ts b/server/src/__tests__/managed-instance-default.test.ts index 597ae7cf3a3..ece255c4894 100644 --- a/server/src/__tests__/managed-instance-default.test.ts +++ b/server/src/__tests__/managed-instance-default.test.ts @@ -1,6 +1,7 @@ +import { randomUUID } from "node:crypto"; 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 { agents, companies, 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"; @@ -18,6 +19,8 @@ const support = await getEmbeddedPostgresTestSupport(); }, 90_000); afterAll(async () => { await database?.cleanup(); }); beforeEach(async () => { + await db.delete(agents); + await db.delete(companies); await db.delete(instanceSettings); await db.delete(environments); const [sandbox] = await db.insert(environments).values({ @@ -47,6 +50,13 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await instanceDefault()).toBe(sandboxId); }); + it("fills with the managed sandbox while active agents follow the default (a sandbox needs no agent setup)", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Managed", issuePrefix: `M${companyId.slice(0, 7)}` }); + await db.insert(agents).values({ companyId, name: "Ready agent", adapterType: "process", defaultEnvironmentId: null }); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: null }); + }); + it("fills a default that names the local environment", async () => { const localId = await environment("Local", "local"); await setInstanceDefault(localId); diff --git a/server/src/__tests__/managed-plugin-environments.test.ts b/server/src/__tests__/managed-plugin-environments.test.ts index 4179d238dbf..023579108d5 100644 --- a/server/src/__tests__/managed-plugin-environments.test.ts +++ b/server/src/__tests__/managed-plugin-environments.test.ts @@ -15,6 +15,10 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro import { environmentService } from "../services/environments.js"; import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "../services/managed-config.js"; import { applyManagedEnvironments } from "../services/managed-environments.js"; +import { fillUnsetInstanceDefault } from "../services/managed-instance-default.js"; +import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js"; +import { createAgentLifecycleEffects } from "../services/agent-lifecycle.js"; +import { createLifecycleStore } from "../modules/agent-lifecycle/adapters/postgres.js"; const PLUGIN_KEY = "example.remote-host-plugin"; const MARKER = `${PLUGIN_KEY}:tasks`; @@ -86,7 +90,8 @@ const support = await getEmbeddedPostgresTestSupport(); version: "1.0.0", status: "ready", manifestJson: { - capabilities: ["environment.drivers.register"], + agentLifecycle: true, + capabilities: ["agents.lifecycle.manage", "environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }], } as never, }); @@ -252,4 +257,115 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await pluginRows())[0]!.status).toBe("active"); }); }); + + describe("instanceDefault (fill only an unset default)", () => { + const defaultEntry = { ...remoteHostEntry, instanceDefault: true }; + const boot = (entries: unknown[] = [defaultEntry]) => + applyManagedEnvironments(db, managedConfig(entries), { env: {}, workerManager, autoInstalledPluginKeys: [PLUGIN_KEY] }); + 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 sandboxRow = async () => { + const [row] = await db.insert(environments).values({ + name: "Managed Sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" }, + metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" }, + }).returning(); + return row!.id; + }; + const agent = async (companyId: string, defaultEnvironmentId: string | null, + lifecycle: Partial> = {}) => { + const id = randomUUID(); + await db.insert(agents).values({ id, companyId, name: `Agent ${id}`, adapterType: "process", defaultEnvironmentId, ...lifecycle }); + return id; + }; + + it("makes the plugin row the default on a new instance (unset default)", async () => { + await boot(); + const [row] = await pluginRows(); + expect(await instanceDefault()).toBe(row!.id); + }); + + it("keeps an existing instance's default, which the managed sandbox already filled", async () => { + const sandboxId = await sandboxRow(); + await setInstanceDefault(sandboxId); + await agent(await company(), null); + await boot(); + await boot(); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("keeps an admin's choice", async () => { + const [chosen] = await db.insert(environments).values({ name: "Admin choice", driver: "ssh", status: "active", config: {} }).returning(); + await setInstanceDefault(chosen!.id); + await boot(); + expect(await instanceDefault()).toBe(chosen!.id); + }); + + it("never takes over later when the plugin was down at the first boot and the sandbox filled", async () => { + running = false; + await boot(); + expect(await instanceDefault()).toBeNull(); + // The same boot's managed sandbox fill (under managed-sandbox-only). + const sandboxId = await sandboxRow(); + await fillUnsetInstanceDefault(db, sandboxId); + + // The plugin recovers mid-process, then on a later boot: no fill either way. + running = true; + for (const listener of [...readyListeners]) listener({ pluginId }); + await vi.waitFor(async () => expect((await pluginRows())[0]?.status).toBe("active")); + await boot(); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("declines an admin-cleared default while an active agent that follows it lacks the plugin's setup", async () => { + await setInstanceDefault(null); + await agent(await company(), null); + await boot(); + expect(await instanceDefault()).toBeNull(); + // The same boot's managed sandbox fill still fills it. + const sandboxId = await sandboxRow(); + expect(await fillUnsetInstanceDefault(db, sandboxId)).toMatchObject({ filled: true }); + expect(await instanceDefault()).toBe(sandboxId); + }); + + it("declines a local default the same way", async () => { + const [local] = await db.insert(environments).values({ name: "Local", driver: "local", status: "active", config: {} }).returning(); + await setInstanceDefault(local!.id); + await agent(await company(), null, { lifecycleState: "verifying" }); + await boot(); + expect(await instanceDefault()).toBe(local!.id); + }); + + it("fills when no active agent following the default needs the plugin's setup", async () => { + const companyId = await company(); + const sandboxId = await sandboxRow(); + await agent(companyId, null, { lifecycleState: "paused" }); + await agent(companyId, null, { lifecycleRequiredPluginIds: [pluginId] }); + await agent(companyId, sandboxId); + await setInstanceDefault(null); + await boot(); + const [row] = await pluginRows(); + expect(await instanceDefault()).toBe(row!.id); + }); + + it("does nothing without the flag", async () => { + await boot([remoteHostEntry]); + expect(await instanceDefault()).toBeNull(); + }); + + it("prepares a new agent that follows the default with the plugin, and one set to the sandbox without it", async () => { + await boot(); + const sandboxId = await sandboxRow(); + const companyId = await company(); + const selection = async (id: string) => + createLifecycleDriver(db, { call } as never) + .requiredPluginIds((await createLifecycleStore(db, createAgentLifecycleEffects()).get(id))!); + expect(await selection(await agent(companyId, null))).toEqual([pluginId]); + expect(await selection(await agent(companyId, sandboxId))).toEqual([]); + }); + }); }); diff --git a/server/src/services/managed-config-capabilities.json b/server/src/services/managed-config-capabilities.json new file mode 100644 index 00000000000..74b8c566409 --- /dev/null +++ b/server/src/services/managed-config-capabilities.json @@ -0,0 +1 @@ +{ "schemaVersion": 1, "capabilities": ["pluginEnvironments"] } diff --git a/server/src/services/managed-config-capabilities.test.ts b/server/src/services/managed-config-capabilities.test.ts new file mode 100644 index 00000000000..db97d78366b --- /dev/null +++ b/server/src/services/managed-config-capabilities.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + MANAGED_CONFIG_CAPABILITIES, + MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION, +} from "./managed-config.js"; + +// The control plane fetches this file at a release commit to learn which +// managed-config sections the release accepts. It must match the parser's +// own list exactly. +const capabilitiesFile = new URL("./managed-config-capabilities.json", import.meta.url); + +describe("managed-config-capabilities.json", () => { + it("matches the capabilities the parser supports", () => { + expect(JSON.parse(readFileSync(capabilitiesFile, "utf8"))).toEqual({ + schemaVersion: MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION, + capabilities: [...MANAGED_CONFIG_CAPABILITIES], + }); + }); + + it("keeps the published contract", () => { + expect(JSON.parse(readFileSync(capabilitiesFile, "utf8"))).toEqual({ + schemaVersion: 1, + capabilities: ["pluginEnvironments"], + }); + }); +}); diff --git a/server/src/services/managed-config.ts b/server/src/services/managed-config.ts index f73d0b16e78..0e39ff40b1b 100644 --- a/server/src/services/managed-config.ts +++ b/server/src/services/managed-config.ts @@ -17,7 +17,8 @@ * "driver": "plugin", * "pluginKey": "example.remote-host-plugin", * "driverKey": "tasks", - * "driverConfig": {} + * "driverConfig": {}, + * "instanceDefault": true * } * ] * } @@ -81,6 +82,25 @@ export interface ManagedInstanceConfig { * before plugin entries existed parse unchanged. */ export const MANAGED_ENVIRONMENT_DRIVERS = ["sandbox", "plugin"] as const; + +/** + * The managed-config capabilities this build supports. The control plane + * reads them from `managed-config-capabilities.json` (next to this file) at + * a release commit, and sends a section only to a release that lists its + * capability. `managed-config-capabilities.test.ts` keeps that file equal to + * this list, and the parser accepts each section only while its capability + * is listed here, so the two cannot drift. + * + * - `pluginEnvironments`: `driver: "plugin"` entries in `environments`, + * including `instanceDefault`. + */ +export const MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION = 1; +export const MANAGED_CONFIG_CAPABILITIES = ["pluginEnvironments"] as const; +export type ManagedConfigCapability = (typeof MANAGED_CONFIG_CAPABILITIES)[number]; + +function supportsCapability(capability: ManagedConfigCapability): boolean { + return (MANAGED_CONFIG_CAPABILITIES as readonly string[]).includes(capability); +} export type ManagedEnvironmentDriver = (typeof MANAGED_ENVIRONMENT_DRIVERS)[number]; /** @@ -106,6 +126,13 @@ export interface ManagedPluginEnvironmentSpec { driverKey: string; /** Driver config, validated by the plugin at ensure time; never carries secrets. */ driverConfig: Readonly>; + /** + * Fill the instance default with this environment when the default is + * unset (or names the local environment or a deleted one), before the + * managed sandbox fills it. A set default is never changed. See + * `fillUnsetInstanceDefault`. + */ + instanceDefault: boolean; } /** @@ -203,6 +230,7 @@ const PLUGIN_ENTRY_KEYS = [ "pluginKey", "driverKey", "driverConfig", + "instanceDefault", ] as const; /** Same rule as the plugin environment config schema in `environment-config.ts`. */ const PLUGIN_DRIVER_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; @@ -254,6 +282,7 @@ function parseManagedEnvironmentsSection( } const names = new Set(); const pluginDrivers = new Set(); + let instanceDefaultIndex: number | null = null; for (const [index, entry] of raw.entries()) { const at = `environments[${index}]`; if (!isPlainObject(entry)) { @@ -266,6 +295,14 @@ function parseManagedEnvironmentsSection( `"${at}.driver" must be one of ${MANAGED_ENVIRONMENT_DRIVERS.map((value) => JSON.stringify(value)).join(", ")} when present (got ${describeJsonValue(entry.driver)})`, ); } + if (driver === "plugin" && !supportsCapability("pluginEnvironments")) { + fail(`"${at}.driver" is "plugin", but this build does not list the "pluginEnvironments" managed-config capability`); + } + if (driver === "sandbox" && entry.instanceDefault !== undefined) { + fail( + `"${at}.instanceDefault" is not allowed on the sandbox entry; only a plugin entry may become the instance default (the sandbox entry's default is governed by enableManagedSandboxOnly)`, + ); + } const allowedKeys: readonly string[] = driver === "plugin" ? PLUGIN_ENTRY_KEYS : SANDBOX_ENTRY_KEYS; for (const key of Object.keys(entry)) { if (!allowedKeys.includes(key)) { @@ -330,6 +367,18 @@ function parseManagedEnvironmentsSection( } pluginDrivers.add(pair); const driverConfig = readSecretFreeObject(entry.driverConfig, `${at}.driverConfig`); + if (entry.instanceDefault !== undefined && typeof entry.instanceDefault !== "boolean") { + fail(`"${at}.instanceDefault" must be a boolean when present (got ${describeJsonValue(entry.instanceDefault)})`); + } + const instanceDefault = entry.instanceDefault === true; + if (instanceDefault) { + if (instanceDefaultIndex !== null) { + fail( + `"${at}.instanceDefault" is also set on environments[${instanceDefaultIndex}]; at most one entry may become the instance default`, + ); + } + instanceDefaultIndex = index; + } plugin.push( Object.freeze({ driver: "plugin", @@ -338,6 +387,7 @@ function parseManagedEnvironmentsSection( pluginKey, driverKey, driverConfig: Object.freeze(driverConfig), + instanceDefault, }) as ManagedPluginEnvironmentSpec, ); } diff --git a/server/src/services/managed-environments-plugin-entries.test.ts b/server/src/services/managed-environments-plugin-entries.test.ts index 26577624a32..991f8bca523 100644 --- a/server/src/services/managed-environments-plugin-entries.test.ts +++ b/server/src/services/managed-environments-plugin-entries.test.ts @@ -404,9 +404,8 @@ describe("applyManagedEnvironments plugin entries", () => { expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled(); }); - 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). + it("never fills the instance default with a plugin entry that does not declare instanceDefault", async () => { + // Without the flag, only the managed sandbox fills an unset default. const fillInstanceDefault = vi.fn(async () => ({ filled: false, previousDefaultEnvironmentId: "plugin-env" })); await applyManagedEnvironments(noDb, configWith( [{ name: "Daytona", provider: "daytona" }, pluginEntry()], @@ -424,6 +423,99 @@ describe("applyManagedEnvironments plugin entries", () => { expect(fillInstanceDefault).toHaveBeenCalledWith("env-1"); }); + describe("instanceDefault", () => { + const sandboxAndComputer = () => configWith( + [{ name: "Daytona", provider: "daytona" }, pluginEntry({ instanceDefault: true })], + { enableManagedSandboxOnly: true }, + ); + + it("fills an unset default with the plugin row first, then lets the sandbox fill", async () => { + // The locked fill decides; here it reports a fill each time it is asked. + const fillInstanceDefault = vi.fn(async (_environmentId: string) => ({ filled: true, previousDefaultEnvironmentId: null })); + await applyManagedEnvironments(noDb, sandboxAndComputer(), { + env: {}, + workerManager: workerManager(), + fillInstanceDefault, + environments: environmentsSeam(), + resolveSandboxProviderDriver: readyDriverResolver(), + resolveEnvironmentPlugin: readyPlugin(), + autoInstalledPluginKeys: [PLUGIN_KEY], + }); + // The plugin row goes first, even though the sandbox entry is ensured + // first; on a new instance the plugin fill wins and the sandbox fill + // then finds the default set. + expect(fillInstanceDefault.mock.calls.map(([environmentId]) => environmentId)).toEqual(["plugin-env", "env-1"]); + }); + + it("lets the sandbox fill when the plugin fill declines because agents need setup", async () => { + const fillInstanceDefault = vi.fn(async (environmentId: string) => environmentId === "plugin-env" + ? { filled: false, previousDefaultEnvironmentId: null, declined: "agents_need_setup" as const } + : { filled: true, previousDefaultEnvironmentId: null }); + await applyManagedEnvironments(noDb, sandboxAndComputer(), { + env: {}, + workerManager: workerManager(), + fillInstanceDefault, + environments: environmentsSeam(), + resolveSandboxProviderDriver: readyDriverResolver(), + resolveEnvironmentPlugin: readyPlugin(), + autoInstalledPluginKeys: [PLUGIN_KEY], + }); + expect(fillInstanceDefault.mock.calls.map(([environmentId]) => environmentId)).toEqual(["plugin-env", "env-1"]); + expect(await fillInstanceDefault.mock.results[1]!.value).toMatchObject({ filled: true }); + }); + + it("does not fill with the plugin row when its plugin is down, and recovery never fills", async () => { + const { handle, fireReady } = recoveryHandle(); + const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null })); + const environments = environmentsSeam(); + const manager = workerManager(false, handle); + await applyManagedEnvironments(noDb, configWith([pluginEntry({ instanceDefault: true })]), { + env: {}, + workerManager: manager, + fillInstanceDefault, + environments, + resolveEnvironmentPlugin: readyPlugin(), + autoInstalledPluginKeys: [PLUGIN_KEY], + }); + expect(fillInstanceDefault).not.toHaveBeenCalled(); + + manager.isRunning.mockReturnValue(true); + fireReady(); + await tick(); + expect(environments.ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1); + expect(fillInstanceDefault).not.toHaveBeenCalled(); + }); + + it("does not fill with the plugin row when its ensure fails", async () => { + const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null })); + await applyManagedEnvironments(noDb, sandboxAndComputer(), { + env: {}, + workerManager: workerManager(), + fillInstanceDefault, + environments: environmentsSeam({ + ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(unprocessable("driverConfig.region is not supported")), + }), + resolveSandboxProviderDriver: readyDriverResolver(), + resolveEnvironmentPlugin: readyPlugin(), + autoInstalledPluginKeys: [PLUGIN_KEY], + }); + // Only the sandbox fill runs. + expect(fillInstanceDefault.mock.calls).toEqual([["env-1"]]); + }); + + it("keeps the plugin ensure counted when its fill fails", async () => { + const result = await applyManagedEnvironments(noDb, configWith([pluginEntry({ instanceDefault: true })]), { + env: {}, + workerManager: workerManager(), + fillInstanceDefault: vi.fn().mockRejectedValue(new Error("db exploded")), + environments: environmentsSeam(), + resolveEnvironmentPlugin: readyPlugin(), + autoInstalledPluginKeys: [PLUGIN_KEY], + }); + expect(result).toMatchObject({ ensured: 1, failed: 0 }); + }); + }); + it("is fail-safe: an ensure failure is logged and counted, not thrown", async () => { const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), { env: {}, diff --git a/server/src/services/managed-environments.ts b/server/src/services/managed-environments.ts index 83e4df19405..7d055bdb8fb 100644 --- a/server/src/services/managed-environments.ts +++ b/server/src/services/managed-environments.ts @@ -48,8 +48,14 @@ * entry: await `pluginsReady`, require the plugin `ready`, declaring the * entry's driver, and running a worker; else archive the entry's row and * re-ensure it on the worker's `ready`. Each entry owns one row (see - * `managed-plugin-environments.ts`). Plugin entries never touch the instance - * default. + * `managed-plugin-environments.ts`). + * + * Instance default: boot fills an unset, local or deleted default once, under + * a lock (`fillUnsetInstanceDefault`), and never changes a set one. A plugin + * entry with `instanceDefault` fills first, right after its ensure; the + * managed sandbox (under `enableManagedSandboxOnly`) fills after all plugin + * entries. So a new instance gets the plugin environment, and an existing + * instance, whose default is already set, keeps its default. */ import type { Db } from "@paperclipai/db"; @@ -302,6 +308,11 @@ export async function applyManagedEnvironments( * on. The write is one locked transaction (`fillUnsetInstanceDefault`), so * overlapping boots and admin writes cannot overwrite each other. * + * Runs once, after the plugin entries: a plugin entry with + * `instanceDefault` fills an unset default first (see + * `fillPluginInstanceDefault`), so on a new instance it wins, and on an + * existing one (whose default is already set) neither fill changes it. + * * Best-effort: a failure degrades to the run-time policy, which refuses * local under managed-sandbox-only regardless. */ @@ -322,6 +333,41 @@ export async function applyManagedEnvironments( } }; + /** + * A plugin entry with `instanceDefault` fills the instance default with its + * row right after a successful boot ensure, through the same locked fill: + * only an unset, local or deleted default is replaced, and a set default + * (an existing instance's managed sandbox, an admin's choice) never is. + * Like an admin's write, the fill declines while active agents following + * the default lack the plugin's setup; the sandbox fill then runs as usual. + * Recovery after boot never fills. Best-effort, like the sandbox fill. + */ + const fillPluginInstanceDefault = async ( + spec: ManagedInstanceConfig["pluginEnvironments"][number], + environmentId: string, + ): Promise => { + if (!spec.instanceDefault) return; + try { + const { filled, previousDefaultEnvironmentId, declined } = await fillInstanceDefault(environmentId); + if (declined) { + logger.warn( + { environmentId, name: spec.name, previousDefaultEnvironmentId, declined }, + "kept the instance default: agents that follow it need setup for the managed plugin environment first", + ); + } + if (!filled) return; + logger.info( + { environmentId, name: spec.name, previousDefaultEnvironmentId }, + "instance default environment set to the managed plugin environment", + ); + } catch (err) { + logger.error( + { err, environmentId, name: spec.name }, + "failed to fill the instance default with the managed plugin environment", + ); + } + }; + // Recovery path for a `ready` plugin whose worker was down at check time: // that shape is usually a crash in restart-backoff, and the manager // re-emits `ready` when it respawns or replaces the worker. A one-shot @@ -405,6 +451,7 @@ export async function applyManagedEnvironments( let unchanged = 0; let skipped = 0; const outcomes: ManagedEnvironmentReconciliationOutcome[] = []; + let sandboxDefaultCandidate: string | null = null; for (const spec of managedConfig.environments) { try { const resolved = await resolveDriver({ db, driverKey: spec.provider }); @@ -469,7 +516,8 @@ export async function applyManagedEnvironments( stockStatus: reconciliation.stockStatus, updateAvailable: reconciliation.updateAvailable, }); - await ensureManagedInstanceDefault(reconciliation.environment); + // Filled after the plugin entries (see `ensureManagedInstanceDefault`). + sandboxDefaultCandidate = reconciliation.environment.id; const logContext = { environmentId: reconciliation.environment.id, name: reconciliation.environment.name, @@ -635,6 +683,7 @@ export async function applyManagedEnvironments( { ...logBase, environmentId: result.environment.id, action: result.action }, "managed plugin environment reconciled", ); + await fillPluginInstanceDefault(spec, result.environment.id); } catch (err) { failed += 1; if (recoverable && !isPermanentConfigRejection(err)) { @@ -667,6 +716,9 @@ export async function applyManagedEnvironments( } } + + if (sandboxDefaultCandidate) await ensureManagedInstanceDefault({ id: sandboxDefaultCandidate }); + return { ensured, failed, diff --git a/server/src/services/managed-instance-default.ts b/server/src/services/managed-instance-default.ts index c08d1e6db89..ef11913a8c9 100644 --- a/server/src/services/managed-instance-default.ts +++ b/server/src/services/managed-instance-default.ts @@ -11,6 +11,12 @@ * default write, can never overwrite a value that another transaction just * committed: whichever commits first wins, and the other sees a set default. * + * The fill obeys the same readiness rule as an admin's default write + * (`assertAgentsReadyForEnvironmentChange`): it declines a target that would + * give active agents following the default a plugin that has not prepared + * them. A managed sandbox target never needs agent setup, so this only ever + * declines a plugin environment. + * * Once filled, the default is an ordinary set value. Nothing reverts it * automatically; an admin can change it in instance settings. */ @@ -19,6 +25,7 @@ import { eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { environments, instanceSettings } from "@paperclipai/db"; import { instanceSettingsService } from "./instance-settings.js"; +import { agentsNeedSetupForEnvironmentChange } from "./plugin-environment-driver.js"; /** Singleton key of the instance settings row that holds `defaultEnvironmentId`. */ const INSTANCE_DEFAULT_SETTINGS_KEY = "default"; @@ -26,11 +33,14 @@ const INSTANCE_DEFAULT_SETTINGS_KEY = "default"; export interface FillInstanceDefaultResult { filled: boolean; previousDefaultEnvironmentId: string | null; + /** Set when the default was fillable but active agents following it need setup for the target first. */ + declined?: "agents_need_setup"; } /** * Point the instance default at `environmentId` only if the current default - * is unset, the local environment, or an environment that no longer exists. + * is unset, the local environment, or an environment that no longer exists, + * and no active agent following the default would need setup for it first. */ export async function fillUnsetInstanceDefault(db: Db, environmentId: string): Promise { return await db.transaction(async (tx) => { @@ -45,14 +55,17 @@ export async function fillUnsetInstanceDefault(db: Db, environmentId: string): P .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 }; - } + const environmentRef = async (id: string) => (await tx + .select({ driver: environments.driver, config: environments.config }) + .from(environments) + .where(eq(environments.id, id)))[0] ?? null; + const currentEnvironment = current === null ? null : await environmentRef(current); + if (currentEnvironment && currentEnvironment.driver !== "local") { + return { filled: false, previousDefaultEnvironmentId: current }; + } + if (await agentsNeedSetupForEnvironmentChange(tx as unknown as Db, currentEnvironment, await environmentRef(environmentId), + { inheritsDefault: true })) { + return { filled: false, previousDefaultEnvironmentId: current, declined: "agents_need_setup" }; } await settings.update({ defaultEnvironmentId: environmentId }, { db: tx }); return { filled: true, previousDefaultEnvironmentId: current }; diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index ad5f170aaa2..88aa2a61726 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -56,18 +56,24 @@ export async function pluginForAgentEnvironment(db: Db, environment: AgentEnviro return plugin && pluginServesAgentEnvironment(plugin, environment) ? plugin : null; } -/** Throws when a saved environment would give active agents a plugin that has not prepared them. */ -export async function assertAgentsReadyForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined, +/** Whether a saved environment change would give active agents a plugin that has not prepared them. */ +export async function agentsNeedSetupForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined, after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) { const plugin = after ? await pluginForAgentEnvironment(db, after) : null; - if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return; + if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return false; const usesEnvironment = or(users.environmentId ? eq(agents.defaultEnvironmentId, users.environmentId) : undefined, users.inheritsDefault ? isNull(agents.defaultEnvironmentId) : undefined); - if (!usesEnvironment) return; + if (!usesEnvironment) return false; const [waiting] = await db.select({ id: agents.id }).from(agents).where(and(usesEnvironment, inArray(agents.lifecycleState, ["ready", "verifying"]), sql`not coalesce(${agents.lifecycleRequiredPluginIds}, '[]'::jsonb) @> ${JSON.stringify([plugin.id])}::jsonb`)).limit(1); - if (waiting) { + return Boolean(waiting); +} + +/** Throws when a saved environment would give active agents a plugin that has not prepared them. */ +export async function assertAgentsReadyForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined, + after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) { + if (await agentsNeedSetupForEnvironmentChange(db, before, after, users)) { throw conflict("Agents that use this environment need setup first. Pause those agents, save this change, and then resume them.", { code: "environment_requires_agent_setup" }); }