mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
**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>
223 lines
9.0 KiB
TypeScript
223 lines
9.0 KiB
TypeScript
/**
|
|
* Cloud managed-config bootstrap (harness → app contract).
|
|
*
|
|
* Instances managed by the Paperclip Cloud harness receive one environment
|
|
* variable, `PAPERCLIP_MANAGED_CONFIG`, holding a single JSON document:
|
|
*
|
|
* {
|
|
* "v": 1,
|
|
* "mode": "cloud",
|
|
* "catalogVersion": "2026.720.0",
|
|
* "features": { "<feature-key>": true | false, ... },
|
|
* "plugins": { "autoInstall": ["daytona", "kubernetes"] }
|
|
* }
|
|
*
|
|
* Parsing follows the `execution-policy-bootstrap.ts` doctrine: a pure
|
|
* function over `Record<string, string | undefined>`, strict, and fail
|
|
* closed — bad JSON, an unsupported `v`, an unknown feature key, a feature
|
|
* key this build's feature catalog does not mark tier "managed", or any
|
|
* malformed section throws with a precise error so a managed instance
|
|
* refuses to start instead of silently dropping a security control. Only an
|
|
* ABSENT env var means self-hosted (no overlay, zero behavior change); a
|
|
* present-but-blank value or a document missing the `features` or
|
|
* `plugins.autoInstall` sections is malformed and fails startup, so a
|
|
* harness misconfiguration can never silently drop the managed overlay.
|
|
*
|
|
* Unlike the execution-policy bootstrap, the parsed document is NEVER
|
|
* persisted. `instanceSettingsService` overlays it at read time, so a DB
|
|
* restore or manual row edit cannot resurrect a capability the harness has
|
|
* disabled (see `applyManagedExperimentalOverlay` in instance-settings.ts).
|
|
*/
|
|
|
|
import {
|
|
INSTANCE_FEATURE_CATALOG,
|
|
instanceExperimentalSettingsSchema,
|
|
type ManagedExperimentalFeatureKey,
|
|
} from "@paperclipai/shared";
|
|
|
|
export type ManagedConfigEnv = Record<string, string | undefined>;
|
|
|
|
export const MANAGED_CONFIG_ENV_KEY = "PAPERCLIP_MANAGED_CONFIG";
|
|
export const SUPPORTED_MANAGED_CONFIG_VERSION = 1;
|
|
|
|
export interface ManagedInstanceConfig {
|
|
v: typeof SUPPORTED_MANAGED_CONFIG_VERSION;
|
|
mode: "cloud";
|
|
/** App feature-catalog version the document was validated against. */
|
|
catalogVersion: string;
|
|
features: Readonly<Partial<Record<ManagedExperimentalFeatureKey, boolean>>>;
|
|
plugins: { readonly autoInstall: readonly string[] };
|
|
}
|
|
|
|
let cachedFeatureKeys: ReadonlySet<string> | null = null;
|
|
|
|
/**
|
|
* The set of feature keys a managed-config document may target: the boolean
|
|
* flag keys of `instanceExperimentalSettingsSchema`. The schema is the
|
|
* manifest — server-managed bookkeeping fields (activation cutoffs, lookback
|
|
* hours) are not booleans and are excluded by construction.
|
|
*/
|
|
export function managedFeatureKeySet(): ReadonlySet<string> {
|
|
if (!cachedFeatureKeys) {
|
|
const defaults = instanceExperimentalSettingsSchema.parse({}) as Record<string, unknown>;
|
|
cachedFeatureKeys = new Set(
|
|
Object.keys(defaults).filter((key) => typeof defaults[key] === "boolean"),
|
|
);
|
|
}
|
|
return cachedFeatureKeys;
|
|
}
|
|
|
|
function fail(detail: string): never {
|
|
throw new Error(`${MANAGED_CONFIG_ENV_KEY} ${detail}`);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function describeJsonValue(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (Array.isArray(value)) return "an array";
|
|
return `${JSON.stringify(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Parse `PAPERCLIP_MANAGED_CONFIG` from a raw env map. Returns null only when
|
|
* the variable is absent (self-hosted). Throws with a precise error on a
|
|
* present-but-blank value or any malformed document so a cloud instance fails
|
|
* to start (fail closed).
|
|
*/
|
|
export function parseManagedConfigEnv(env: ManagedConfigEnv): ManagedInstanceConfig | null {
|
|
const raw = env[MANAGED_CONFIG_ENV_KEY];
|
|
if (raw === undefined) return null;
|
|
if (raw.trim().length === 0) {
|
|
fail(
|
|
"is set but blank; a managed instance requires the full JSON document (unset the variable entirely for self-hosted mode)",
|
|
);
|
|
}
|
|
|
|
let doc: unknown;
|
|
try {
|
|
doc = JSON.parse(raw);
|
|
} catch (err) {
|
|
fail(`is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
if (!isPlainObject(doc)) {
|
|
fail(`must be a JSON object (got ${describeJsonValue(doc)})`);
|
|
}
|
|
|
|
const allowedTopLevelKeys = new Set(["v", "mode", "catalogVersion", "features", "plugins"]);
|
|
for (const key of Object.keys(doc)) {
|
|
if (!allowedTopLevelKeys.has(key)) {
|
|
fail(`has unknown top-level key "${key}" (allowed: v, mode, catalogVersion, features, plugins)`);
|
|
}
|
|
}
|
|
|
|
if (doc.v !== SUPPORTED_MANAGED_CONFIG_VERSION) {
|
|
fail(
|
|
`has unsupported "v" ${describeJsonValue(doc.v)}; this build supports v=${SUPPORTED_MANAGED_CONFIG_VERSION}`,
|
|
);
|
|
}
|
|
if (doc.mode !== "cloud") {
|
|
fail(`has invalid "mode" ${describeJsonValue(doc.mode)}; expected "cloud"`);
|
|
}
|
|
if (typeof doc.catalogVersion !== "string" || doc.catalogVersion.trim().length === 0) {
|
|
fail(`requires a non-empty string "catalogVersion" (got ${describeJsonValue(doc.catalogVersion)})`);
|
|
}
|
|
|
|
// The cloud contract is the FULL document: `features` and
|
|
// `plugins.autoInstall` are required (empty {} / [] are fine). A missing
|
|
// section means a truncated or mis-built document, and defaulting it to
|
|
// empty would silently drop the managed overlay or auto-install list.
|
|
// `plugins.autoInstall` is validated here as part of the atomic v1
|
|
// document; the bundled-plugin provisioning path that consumes it lands in
|
|
// the follow-up PR that generalizes `ensureBundledKubernetesPlugin`.
|
|
const features: Partial<Record<ManagedExperimentalFeatureKey, boolean>> = {};
|
|
if (doc.features === undefined) {
|
|
fail(`requires a "features" object mapping feature key → boolean (use {} for none)`);
|
|
}
|
|
if (!isPlainObject(doc.features)) {
|
|
fail(`"features" must be an object mapping feature key → boolean (got ${describeJsonValue(doc.features)})`);
|
|
}
|
|
const knownKeys = managedFeatureKeySet();
|
|
for (const [key, value] of Object.entries(doc.features)) {
|
|
if (!knownKeys.has(key)) {
|
|
fail(
|
|
`"features" has unknown feature key "${key}"; known keys are the boolean flags of instanceExperimentalSettingsSchema`,
|
|
);
|
|
}
|
|
// Catalog-compatibility enforcement: the key exists in this build, but the
|
|
// control plane may only manage keys this build's feature catalog marks
|
|
// tier "managed". A key whose tier differs (a tenant `preference`, a
|
|
// code-pinned `floor`, or a tier demoted since the document's
|
|
// `catalogVersion` was published) is version skew — refuse startup rather
|
|
// than apply a control with mismatched catalog semantics.
|
|
const tier = INSTANCE_FEATURE_CATALOG[key as ManagedExperimentalFeatureKey].tier;
|
|
if (tier !== "managed") {
|
|
fail(
|
|
`"features" key "${key}" has tier "${tier}" in this build's feature catalog; only tier "managed" keys may be set by a managed-config document (catalogVersion ${JSON.stringify(doc.catalogVersion)} is incompatible with this build)`,
|
|
);
|
|
}
|
|
if (typeof value !== "boolean") {
|
|
fail(`"features.${key}" must be a boolean (got ${describeJsonValue(value)})`);
|
|
}
|
|
features[key as ManagedExperimentalFeatureKey] = value;
|
|
}
|
|
|
|
const autoInstall: string[] = [];
|
|
if (doc.plugins === undefined) {
|
|
fail(`requires a "plugins" object with an "autoInstall" array (use { "autoInstall": [] } for none)`);
|
|
}
|
|
if (!isPlainObject(doc.plugins)) {
|
|
fail(`"plugins" must be an object (got ${describeJsonValue(doc.plugins)})`);
|
|
}
|
|
for (const key of Object.keys(doc.plugins)) {
|
|
if (key !== "autoInstall") {
|
|
fail(`"plugins" has unknown key "${key}" (allowed: autoInstall)`);
|
|
}
|
|
}
|
|
const rawAutoInstall = doc.plugins.autoInstall;
|
|
if (rawAutoInstall === undefined) {
|
|
fail(`requires a "plugins.autoInstall" array of plugin keys (use [] for none)`);
|
|
}
|
|
if (!Array.isArray(rawAutoInstall)) {
|
|
fail(`"plugins.autoInstall" must be an array of plugin keys (got ${describeJsonValue(rawAutoInstall)})`);
|
|
}
|
|
for (const entry of rawAutoInstall) {
|
|
if (typeof entry !== "string" || entry.length === 0 || entry.trim() !== entry) {
|
|
fail(
|
|
`"plugins.autoInstall" entries must be non-empty strings without surrounding whitespace (got ${describeJsonValue(entry)})`,
|
|
);
|
|
}
|
|
if (autoInstall.includes(entry)) {
|
|
fail(`"plugins.autoInstall" has duplicate entry "${entry}"`);
|
|
}
|
|
autoInstall.push(entry);
|
|
}
|
|
|
|
return Object.freeze({
|
|
v: SUPPORTED_MANAGED_CONFIG_VERSION,
|
|
mode: "cloud",
|
|
catalogVersion: doc.catalogVersion,
|
|
features: Object.freeze(features),
|
|
plugins: Object.freeze({ autoInstall: Object.freeze(autoInstall) }),
|
|
}) as ManagedInstanceConfig;
|
|
}
|
|
|
|
let cache: { raw: string | undefined; config: ManagedInstanceConfig | null } | null = null;
|
|
|
|
/**
|
|
* Parse-once accessor keyed on the raw env value. Callers that pass a custom
|
|
* env (tests) get a fresh parse whenever the raw value differs; process.env
|
|
* callers share one parsed document for the process lifetime.
|
|
*/
|
|
export function getManagedInstanceConfig(
|
|
env: ManagedConfigEnv = process.env,
|
|
): ManagedInstanceConfig | null {
|
|
const raw = env[MANAGED_CONFIG_ENV_KEY];
|
|
if (cache && cache.raw === raw) return cache.config;
|
|
const config = parseManagedConfigEnv(env);
|
|
cache = { raw, config };
|
|
return config;
|
|
}
|