Files
PaperClipAI/server/src/__tests__/instance-settings-managed-overlay.test.ts
T
Devin FoleyandPaperclip 216d3d2680 Managed-instance config: fail-closed PAPERCLIP_MANAGED_CONFIG parsing and read-time settings overlay (#10058)
**Builds on.** #10055 — the `catalogVersion` this config document pins
is the feature-catalog artifact #10055 emits.

**Summary.** Instances operated by a managed hosting control plane can
now receive instance configuration through a single environment
variable, `PAPERCLIP_MANAGED_CONFIG` (versioned JSON: `mode`,
`catalogVersion`, `features`, `plugins.autoInstall`). When the variable
is absent the instance is self-hosted and nothing changes. When present,
parsing is strict and **fail-closed**: blank value, malformed JSON,
unknown feature key, a feature key this build's feature catalog does not
mark tier `managed`, missing required section, or unsupported version
refuses startup with a precise error — a typo that silently does nothing
is how a security control quietly fails. Managed feature values are
overlaid **at read time** inside the instance settings service (never
persisted), so a DB restore or manual row edit cannot resurrect a
disabled capability; responses expose per-key `managedKeys` metadata
(`managed: true`, `managedBy`) so clients can render locked state.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip runs both self-hosted and under managed hosting, where an
operator's control plane owns instance configuration
> - Today instance feature settings live only in the tenant database; a
hosting control plane has no way to enforce a configuration that
tenant-side writes or restores cannot undo
> - Managed configuration will carry security posture, so delivery must
be atomic and parsing must fail closed — a typo that silently does
nothing is how a security control quietly fails
> - This pull request adds strict parsing of one
`PAPERCLIP_MANAGED_CONFIG` env var and overlays its feature values at
read time inside the settings service, never persisting them
> - The benefit is a minimal, auditable managed-hosting contract: absent
var ⇒ self-hosted instances are byte-for-byte unchanged; present ⇒
deterministic, locked configuration surfaced to clients via per-key
managed metadata

## Linked Issues or Issue Description

Refs #966 — this PR delivers that issue's "managed config injection"
hook, via a strict env-var contract rather than the config-file path it
sketches; the issue's other hooks (identity header, health, usage
webhook, lifecycle, external secrets, IAM auth) are out of scope, so the
PR refs rather than closes it.

*Mechanism differs from #966's proposal, so the `feature_request` fields
are also filled in:*

- **Problem or motivation:** managed hosting deployments need to
centrally enable/disable instance features; DB-stored settings can be
edited, restored, or migrated back to permissive values, and nothing
marks a value as operator-enforced.
- **Proposed solution:** one versioned JSON env var; fail-closed parse
at startup; read-time overlay in the settings service (precedence:
managed value over stored value over schema default); `managedKeys`
metadata in settings responses so clients can render locked state.
- **Alternatives considered:** per-feature env vars (non-atomic across a
half-updated env set, unbounded env surface); seeding the DB at boot
(persisted values can be edited or restored over, and cannot express
"forced"); lenient warn-and-drop parsing (fails open — unacceptable for
a security-bearing control).
- **Roadmap alignment:** supports the in-progress "Cloud deployments"
milestone in `ROADMAP.md`.

## What Changed

- New `server/src/services/managed-config.ts` (pure parser over the env
record)
- Startup parse ordered before the first `instanceSettingsService`
construction in `server/src/index.ts`
- Read-time merge + `managedKeys` in the settings service
- Shared validator updates

## Verification

- 29 parser/overlay tests (fail-closed matrix incl. blank/whitespace
env, missing sections, catalog-tier mismatch, empty-section happy path):
`pnpm vitest run src/__tests__/managed-config.test.ts
src/__tests__/instance-settings-managed-overlay.test.ts` (from
`server/`)
- 40 existing settings route/service tests green: `pnpm vitest run
src/__tests__/instance-settings-routes.test.ts
src/__tests__/instance-settings-service.test.ts` (from `server/`)
- 15 shared validator tests: `pnpm vitest run
src/validators/instance.test.ts` (from `packages/shared/`)
- Server `tsc --noEmit` clean: `pnpm typecheck` (from `server/`)

## Risks

- Self-hosted instances (no `PAPERCLIP_MANAGED_CONFIG` set) are
byte-for-byte unchanged — the parser only runs when the variable is
present.
- For managed instances, a malformed document now refuses startup by
design (fail-closed). This is an intentional behavioral guarantee, not a
regression: the control plane owns the variable and a precise startup
error is the contract.
- Overlay values are never persisted, so no migration or data-shape
risk.

## Model Used

Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use;
independently peer-reviewed by a second AI agent before push.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-22 19:48:43 -07:00

167 lines
6.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import {
applyManagedExperimentalOverlay,
instanceSettingsService,
normalizeExperimentalSettings,
} from "../services/instance-settings.js";
import { parseManagedConfigEnv } from "../services/managed-config.js";
const MANAGED_RAW = JSON.stringify({
v: 1,
mode: "cloud",
catalogVersion: "2026.720.0",
// enableApps stored true in the DB gets forced off; enablePipelines has no
// stored value, so the overlay wins over the schema default (false).
features: { enableApps: false, enablePipelines: true },
plugins: { autoInstall: [] },
});
function managedEnv(raw: string | undefined = MANAGED_RAW) {
return { PAPERCLIP_MANAGED_CONFIG: raw };
}
/**
* Minimal stand-in for the drizzle query chains instanceSettingsService uses.
* Captures every `update().set()` payload so tests can assert what would be
* persisted.
*/
function stubDb(row: Record<string, unknown>) {
const persistedSets: Array<Record<string, unknown>> = [];
const db = {
select: () => ({ from: () => ({ where: () => Promise.resolve([row]) }) }),
insert: () => {
throw new Error("unexpected insert in test");
},
update: () => ({
set: (values: Record<string, unknown>) => {
persistedSets.push(values);
return { where: () => ({ returning: () => Promise.resolve([{ ...row, ...values }]) }) };
},
}),
} as unknown as Db;
return { db, persistedSets };
}
function settingsRow(experimental: Record<string, unknown>) {
return {
id: "row-1",
singletonKey: "default",
defaultEnvironmentId: null,
general: {},
experimental,
createdAt: new Date("2026-06-20T00:00:00.000Z"),
updatedAt: new Date("2026-06-20T00:00:00.000Z"),
};
}
describe("applyManagedExperimentalOverlay", () => {
it("is the identity with no managed config (self-hosted)", () => {
const experimental = normalizeExperimentalSettings({ enableApps: true });
const result = applyManagedExperimentalOverlay(experimental, null);
expect(result.experimental).toEqual(experimental);
expect(result.managedKeys).toEqual({});
});
it("overlays managed values over stored values and records metadata", () => {
const config = parseManagedConfigEnv(managedEnv())!;
const stored = normalizeExperimentalSettings({ enableApps: true });
const { experimental, managedKeys } = applyManagedExperimentalOverlay(stored, config);
// managed overlay > tenant DB value
expect(experimental.enableApps).toBe(false);
// managed overlay > schema default
expect(experimental.enablePipelines).toBe(true);
// unmanaged keys keep their stored/default values
expect(experimental.enableCases).toBe(false);
expect(managedKeys).toEqual({
enableApps: { managed: true, managedBy: "paperclip-cloud" },
enablePipelines: { managed: true, managedBy: "paperclip-cloud" },
});
// input is not mutated
expect(stored.enableApps).toBe(true);
});
});
describe("instanceSettingsService managed overlay", () => {
it("fails closed at construction on a malformed managed config", () => {
const { db } = stubDb(settingsRow({}));
expect(() => instanceSettingsService(db, { runtimeEnv: managedEnv("{bad") })).toThrow(
/PAPERCLIP_MANAGED_CONFIG is not valid JSON/,
);
});
it("overlays managed values on getExperimental and exposes managedKeys", async () => {
const { db } = stubDb(settingsRow({ enableApps: true }));
const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() });
const experimental = await svc.getExperimental();
expect(experimental.enableApps).toBe(false);
expect(experimental.enablePipelines).toBe(true);
expect(experimental.managedKeys).toEqual({
enableApps: { managed: true, managedBy: "paperclip-cloud" },
enablePipelines: { managed: true, managedBy: "paperclip-cloud" },
});
});
it("overlays managed values on get()", async () => {
const { db } = stubDb(settingsRow({ enableApps: true }));
const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() });
const settings = await svc.get();
expect(settings.experimental.enableApps).toBe(false);
expect(settings.experimental.managedKeys?.enableApps).toEqual({
managed: true,
managedBy: "paperclip-cloud",
});
});
it("leaves the self-hosted read path unchanged (no managedKeys field)", async () => {
const { db } = stubDb(settingsRow({ enableApps: true }));
const svc = instanceSettingsService(db, { runtimeEnv: {} });
const experimental = await svc.getExperimental();
expect(experimental.enableApps).toBe(true);
expect(Object.prototype.hasOwnProperty.call(experimental, "managedKeys")).toBe(false);
expect(experimental).toEqual(normalizeExperimentalSettings({ enableApps: true }));
const settings = await svc.get();
expect(Object.prototype.hasOwnProperty.call(settings.experimental, "managedKeys")).toBe(false);
});
it("never persists the overlay: updates write stored values, responses show managed ones", async () => {
const { db, persistedSets } = stubDb(settingsRow({ enableApps: true }));
const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() });
const updated = await svc.updateExperimental({ enableCases: true });
expect(persistedSets).toHaveLength(1);
const persisted = persistedSets[0]!.experimental as Record<string, unknown>;
// The tenant's stored value survives in the DB even though the overlay
// masks it at read time — a later un-managing restores tenant intent.
expect(persisted.enableApps).toBe(true);
// The overlay-added value is not written.
expect(persisted.enablePipelines).toBe(false);
expect(persisted.enableCases).toBe(true);
expect(persisted).not.toHaveProperty("managedKeys");
// The response still reflects the overlay.
expect(updated.experimental.enableApps).toBe(false);
expect(updated.experimental.enablePipelines).toBe(true);
expect(updated.experimental.managedKeys?.enableApps).toEqual({
managed: true,
managedBy: "paperclip-cloud",
});
});
it("does not let managed metadata leak into self-hosted writes", async () => {
const { db, persistedSets } = stubDb(settingsRow({}));
const svc = instanceSettingsService(db, { runtimeEnv: {} });
const updated = await svc.updateExperimental({ enableCases: true });
expect(persistedSets).toHaveLength(1);
expect(persistedSets[0]!.experimental).not.toHaveProperty("managedKeys");
expect(Object.prototype.hasOwnProperty.call(updated.experimental, "managedKeys")).toBe(false);
});
});