diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index f26bebfdad..5167ae4037 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -1389,6 +1389,90 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(activity.at(-1)?.action).toBe("environment.managed_stock_skipped"); }); + it("platformFullyManaged applies drift instead of preserving it, since no operator can edit this row", async () => { + const companyId = await seedCompany(); + const created = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + description: "Managed stock", + provider: "daytona", + config: { target: "us" }, + stockVersion: "v1", + platformFullyManaged: true, + }); + // Same drift shape as the plain "classifies operator drift" case above: + // from the reconciler's point of view, a row whose content matches + // neither the recorded binding hash nor the latest stock hash is + // indistinguishable between "an operator edited it" and "two + // platform-driven reconciliation passes disagreed" (e.g. a stock hash + // recorded by an older app build). `platformFullyManaged` asserts the + // caller's deployment rules out the former, so this must apply like any + // other stock-outdated row rather than freeze the row and only bump the + // binding's bookkeeping. + await db + .update(environments) + .set({ + config: { provider: "daytona", target: "drifted" }, + }) + .where(eq(environments.id, created.environment.id)); + + const reconciled = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona v2", + description: "Managed stock v2", + provider: "daytona", + config: { target: "eu" }, + stockVersion: "v2", + platformFullyManaged: true, + }); + + expect(reconciled).toMatchObject({ + action: "updated", + stockStatus: "stock_update_available", + updateAvailable: false, + }); + expect(reconciled.environment).toMatchObject({ + name: "Daytona v2", + description: "Managed stock v2", + config: { provider: "daytona", target: "eu" }, + }); + const [bindingAfter] = await db + .select() + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)); + expect(bindingAfter?.stockVersion).toBe("v2"); + expect(bindingAfter?.stockHash).toBe(reconciled.stockHash); + }); + + it("platformFullyManaged still preserves an operator-reaffirmed archive decision", async () => { + const companyId = await seedCompany(); + const created = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + provider: "daytona", + config: { target: "us" }, + platformFullyManaged: true, + }); + expect((await svc.archiveManagedSandboxEnvironment({ provider: "daytona" }))?.status) + .toBe("archived"); + expect((await svc.update(created.environment.id, { status: "archived" }))?.status) + .toBe("archived"); + + const reconciled = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + provider: "daytona", + config: { target: "us" }, + platformFullyManaged: true, + }); + expect(reconciled).toMatchObject({ + action: "skipped", + stockStatus: "operator_modified", + updateAvailable: true, + environment: { status: "archived" }, + }); + }); + it("adopts the managed slot on a provider switch and drops the stale kubernetes marker", async () => { const companyId = await seedCompany(); const kubernetes = await svc.ensureKubernetesEnvironment(companyId, { inCluster: true, backend: "job" }); @@ -1579,6 +1663,44 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(rows).toHaveLength(1); }); + it("platformFullyManaged never adopts an unbound same-name sandbox row", async () => { + // Same setup as the plain case above: a tenant-created sandbox row holds + // the desired name and has no stock binding. It reads as + // `operator_modified` too, but there is no prior platform pass for it to + // have drifted from — the bypass must require a binding for this exact + // row, or it would overwrite the tenant's config and stamp it managed. + const companyId = await seedCompany(); + const handMade = await svc.create({ + name: "Daytona", + driver: "sandbox", + status: "active", + config: { provider: "daytona", target: "us" }, + }); + expect(handMade.metadata?.managedByPaperclip).toBeUndefined(); + + const reconciliation = await svc.ensureManagedSandboxEnvironment({ + companyId, + name: "Daytona", + provider: "daytona", + config: { target: "eu" }, + platformFullyManaged: true, + }); + expect(reconciliation).toMatchObject({ + action: "skipped", + stockStatus: "operator_modified", + updateAvailable: true, + }); + expect(reconciliation.environment.id).toBe(handMade.id); + expect(reconciliation.environment.config.target).toBe("us"); + expect(reconciliation.environment.metadata?.managedByPaperclip).toBeUndefined(); + + const rows = await db + .select() + .from(environments) + .where(eq(environments.driver, "sandbox")); + expect(rows).toHaveLength(1); + }); + it("keeps the current name when the desired name belongs to another row", async () => { const companyId = await seedCompany(); await svc.create({ diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 393b24bbf7..84a13c77d6 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -105,6 +105,18 @@ export interface ManagedSandboxEnvironmentInput { extraMetadata?: Record; /** Version label recorded with the stock binding; hashes remain the drift authority. */ stockVersion?: string; + /** + * Asserts the caller's deployment gives no operator any path to hand-edit + * this row (currently only true for the PAPERCLIP_MANAGED_CONFIG applier, + * where `enableManagedSandboxOnly` removes the tenant's own environment + * choice entirely). When set, a plain content-hash mismatch against a real + * prior binding is treated as ordinary stock drift instead of an operator + * customization to protect — see the `operator_modified` handling below. + * Leave unset for any caller (self-hosted `kubernetes-execution-mode` + * bootstrap, tests, admin routes) where an operator could realistically + * have edited the row through the normal environments UI/API. + */ + platformFullyManaged?: boolean; } export type ManagedSandboxEnvironmentReconcileAction = @@ -347,6 +359,24 @@ export function environmentService(db: Db) { ? [input.companyId] : await tx.select({ id: companies.id }).from(companies).then((rows) => rows.map((row) => row.id)); activityCompanyIds = companyIds; + + // Take the sandbox-row lock BEFORE reading the stock bindings. Two + // passes can reconcile the same slot concurrently (the boot ensure and + // the async provider-recovery reactivation, or two app builds during a + // rolling deploy). Concurrent passes serialize on this lock, and under + // READ COMMITTED each later statement sees a fresh snapshot — so a pass + // that blocks here then reads the bindings the winning pass committed, + // not a snapshot from before it waited. Reading bindings first left a + // window where a waiting pass compared the winner's fresh row against + // its own stale binding hash, misclassified the mismatch as + // `operator_modified`, and — once `platformFullyManaged` turns that + // into an update — rolled the row back to its own older stock. + const sandboxRows = await tx + .select() + .from(environments) + .where(eq(environments.driver, "sandbox")) + .for("update"); + const bindingConditions = and( eq(builtInManagedResources.bundleKey, MANAGED_ENVIRONMENT_BUNDLE_KEY), eq(builtInManagedResources.resourceKind, MANAGED_ENVIRONMENT_RESOURCE_KIND), @@ -359,11 +389,6 @@ export function environmentService(db: Db) { trackingInitialized = bindings.length < companyIds.length; const keys = managedMetadataKeys(desiredMetadata, bindings); - const sandboxRows = await tx - .select() - .from(environments) - .where(eq(environments.driver, "sandbox")) - .for("update"); let row = sandboxRows.find( (candidate) => (candidate.metadata as Record | null)?.managedByPaperclip === true, ) ?? sandboxRows.find((candidate) => candidate.name === input.name) ?? null; @@ -540,6 +565,39 @@ export function environmentService(db: Db) { ); if (operatorReaffirmedArchive) stockStatus = "operator_modified"; + // `platformFullyManaged` callers (currently: the PAPERCLIP_MANAGED_CONFIG + // applier) assert that nothing in their deployment can hand-edit this + // row — the product gives a cloud-harness tenant no path to it, unlike + // the general self-hosted contract this function otherwise protects + // (see "classifies operator drift" in environment-service.test.ts, + // which exercises a real operator edit and must keep winning). Under + // that assertion, a plain content-hash mismatch against a real prior + // binding can only be drift between two platform-driven reconciliation + // passes (e.g. a stock hash recorded by an older app build before a + // later stock field was added), never a customization to protect — + // apply it like any other stock-outdated row. + // + // Two things the bypass must never touch: + // - A row with NO matching binding. `row` can be a same-name sandbox + // row that was never Paperclip-managed (the fallback lookup above). + // It also reads as `operator_modified`, but there is no prior + // platform pass to have drifted from — adopting it would overwrite + // a tenant-created environment and stamp it managed. Require a + // binding for this exact row, so "prior binding" is enforced, not + // just documented. + // - Archive-reaffirmation. A `sandbox_image` update must never + // resurrect a row something else deliberately kept archived after + // Paperclip's own provider-unavailability archival, so that path + // still skips below regardless of this flag. + if ( + input.platformFullyManaged && + stockStatus === "operator_modified" && + !operatorReaffirmedArchive && + matchingBindings.length > 0 + ) { + stockStatus = "stock_update_available"; + } + if (stockStatus === "operator_modified") { const baseline = matchingBindings[0]; let baselineDefaults = baseline diff --git a/server/src/services/managed-environments.test.ts b/server/src/services/managed-environments.test.ts index 0edc50fd4d..2a38e6c076 100644 --- a/server/src/services/managed-environments.test.ts +++ b/server/src/services/managed-environments.test.ts @@ -171,6 +171,7 @@ describe("applyManagedEnvironments", () => { provider: "daytona", config: { target: "us" }, stockVersion: "2026.720.0", + platformFullyManaged: true, }); // The frozen parsed config must not leak into the service (the row's // config is mutated downstream when the provider key is forced in). @@ -346,6 +347,7 @@ describe("applyManagedEnvironments", () => { provider: "daytona", config: { target: "us" }, stockVersion: "2026.720.0", + platformFullyManaged: true, }); expect(handle.off).toHaveBeenCalledTimes(1); }); diff --git a/server/src/services/managed-environments.ts b/server/src/services/managed-environments.ts index 65bd9ece49..d201728cb7 100644 --- a/server/src/services/managed-environments.ts +++ b/server/src/services/managed-environments.ts @@ -285,6 +285,7 @@ export async function applyManagedEnvironments( provider: spec.provider, config: { ...spec.config }, stockVersion: managedConfig.catalogVersion, + platformFullyManaged: true, }) .then((result) => { logger.info( @@ -364,6 +365,7 @@ export async function applyManagedEnvironments( provider: spec.provider, config: { ...spec.config }, stockVersion: managedConfig.catalogVersion, + platformFullyManaged: true, }); if (reconciliation.action === "skipped") skipped += 1; else {