mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its instance-level environments from the `PAPERCLIP_MANAGED_CONFIG` document, which can declare plugin environments next to the managed sandbox > - A platform that runs managed instances wants a new instance to start with one of those plugin environments as its default, without changing the default of any instance that already has one > - #19632 made boot fill the instance default only when it is unset, local or deleted, in one locked transaction, and removed the revert, so every existing managed instance keeps a set default > - This pull request adds an `instanceDefault` flag to a plugin entry. That entry fills an unset default through the same locked fill, before the managed sandbox does > - The fill follows the same readiness rule as an admin's default write, so it never moves ready agents onto a plugin that has not prepared them > - The platform also needs a way to know whether a release understands plugin entries, so the release now ships a small capabilities file > - The benefit: new instances get the plugin environment as their default, and existing instances and admin choices are never changed ## Linked Issues or Issue Description Refs #19632 (the locked fill-if-unset rule this builds on), #19508 (plugin entries in the managed config) **Subsystem affected** Server environments: the managed config parser and the managed environment bootstrap. **Problem or motivation** The managed config can declare plugin environments, but it can't make one the instance default on a new instance. An external platform also has no way to tell whether a given release supports plugin entries before it sends one. **Proposed solution** - Add `instanceDefault: true` to at most one plugin entry. Right after that entry's boot ensure succeeds, it fills the instance default through `fillUnsetInstanceDefault`. The managed sandbox fill runs after it. - On a new instance the plugin environment wins. On an existing instance the default is already set, so neither fill changes it. - The fill declines while an active agent that follows the default hasn't been prepared by that plugin. This is the rule the instance-settings route enforces. When the plugin fill declines, the managed sandbox fills as usual. - Ship `server/src/services/managed-config-capabilities.json`, which lists `pluginEnvironments`. **Alternatives considered** - A recorded one-time decision with a retry worker. It needed pending states and extra checks for many writer and trigger combinations. Filling only an unset default, under the existing lock, needs none of that. - Rejecting the fill whenever agents exist. That is stricter than the admin route and blocks harmless cases, such as agents that are paused or already prepared. **Roadmap alignment** This extends existing managed-environment behaviour. It adds no UI. ## What Changed - `server/src/services/managed-config.ts`: - `ManagedPluginEnvironmentSpec.instanceDefault` (boolean, default `false`). - The parser refuses `instanceDefault` on more than one plugin entry, on the sandbox entry (even `false`), or as a non-boolean. - `MANAGED_CONFIG_CAPABILITIES` and `MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION` constants. A plugin entry is refused unless `pluginEnvironments` is listed. - `server/src/services/managed-config-capabilities.json`: `{ "schemaVersion": 1, "capabilities": ["pluginEnvironments"] }`. A test pins it to the constants and to this literal contract. - `server/src/services/managed-environments.ts`: - New `fillPluginInstanceDefault`. It runs right after a successful boot ensure of the flagged entry and logs a fill or a decline. Recovery after boot never fills. - The managed sandbox fill now runs once, after the plugin entries. - `server/src/services/managed-instance-default.ts`: `fillUnsetInstanceDefault` runs `agentsNeedSetupForEnvironmentChange` inside its locked transaction and returns `declined: "agents_need_setup"`. A sandbox target never needs agent setup, so the managed sandbox fill behaves as before. - `server/src/services/plugin-environment-driver.ts`: the readiness query is factored into `agentsNeedSetupForEnvironmentChange`. `assertAgentsReadyForEnvironmentChange` wraps it and still throws the same conflict. - `doc/DEVELOPING.md`: documents `instanceDefault`, the fill order, the readiness decline, and the capabilities file. ## Verification - `cd server && npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres), under `instanceDefault (fill only an unset default)`: - A new instance gets the plugin row as its default. - An existing sandbox default is kept, and so is an admin's choice. - If the plugin was down at the first boot and the sandbox filled, a later recovery and boot keep the sandbox default. - An admin-cleared or local default is declined while a ready or verifying agent follows the default without the plugin's setup. The managed sandbox then fills it. - It fills when the agents that follow the default are paused or already prepared, or are pinned to another environment. - Without the flag, nothing happens. - A new agent that follows the default gets the plugin in `requiredPluginIds`. - `npx vitest run src/__tests__/managed-instance-default.test.ts` (embedded Postgres): the earlier fill, lock and overlap cases pass. A sandbox fill also succeeds while active agents follow the default. - `npx vitest run src/services/managed-environments-plugin-entries.test.ts`: - The fill order is plugin then sandbox. - When the plugin is down there is no fill, and recovery never fills. - A failed ensure gives only the sandbox fill, and a failed fill keeps the ensure counted. - When the plugin fill declines, the sandbox still fills. - `npx vitest run src/__tests__/managed-config.test.ts src/services/managed-config-capabilities.test.ts`: parser refusals and the capabilities contract. - `npx vitest run src/services/managed-environments.test.ts src/__tests__/instance-settings-routes.test.ts src/__tests__/environment-routes.test.ts`: pass. The route still throws the same readiness conflict. - `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 - An instance whose default is unset at the first boot after this release gets the plugin environment, if its agents are ready for it. That covers a new instance, and an instance whose admin cleared the default or chose the local environment. This is the intended behaviour. - The readiness check doesn't lock agent rows. If an agent becomes ready during the boot fill, it can miss the check, which is the same race the admin route has. Agents created after the fill are prepared for the plugin, because setup reads the current default. - If the plugin is down at a new instance's first boot, the instance keeps the managed sandbox default from then on. - No schema change. > 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 <noreply@anthropic.com>
611 lines
27 KiB
TypeScript
611 lines
27 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", "remote-host"] },
|
||
* "environments": [
|
||
* { "name": "Daytona", "provider": "daytona", "config": { "target": "us" } },
|
||
* {
|
||
* "name": "Remote Host",
|
||
* "driver": "plugin",
|
||
* "pluginKey": "example.remote-host-plugin",
|
||
* "driverKey": "tasks",
|
||
* "driverConfig": {},
|
||
* "instanceDefault": true
|
||
* }
|
||
* ]
|
||
* }
|
||
*
|
||
* An `environments` entry without `driver` is the single sandbox entry; any
|
||
* number of `driver: "plugin"` entries may sit beside it (see
|
||
* `MANAGED_ENVIRONMENT_DRIVERS`).
|
||
*
|
||
* 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[];
|
||
readonly workerEnv?: Readonly<Record<string, readonly string[]>>;
|
||
readonly enabledCompanies?: Readonly<Record<string, readonly string[]>>;
|
||
};
|
||
/**
|
||
* The sandbox entry of the `environments` section: zero or one, since it
|
||
* provisions the single Paperclip-managed sandbox slot (empty when the
|
||
* section is absent or declares only plugin entries).
|
||
*/
|
||
environments: readonly ManagedEnvironmentSpec[];
|
||
/** The `driver: "plugin"` entries of the `environments` section (empty when none are declared). */
|
||
pluginEnvironments: readonly ManagedPluginEnvironmentSpec[];
|
||
}
|
||
|
||
/**
|
||
* The environment drivers a managed-config `environments` entry may declare.
|
||
* An entry without `driver` is the sandbox entry, so documents written
|
||
* 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];
|
||
|
||
/**
|
||
* One declared managed plugin environment: an instance-level `plugin`-driver
|
||
* environment row served by an auto-installed plugin's environment driver.
|
||
* The row is
|
||
* identified by its `pluginKey`–`driverKey` pair, which boot stamps on the
|
||
* row as `metadata.managedPluginEnvironment`.
|
||
*/
|
||
export interface ManagedPluginEnvironmentSpec {
|
||
driver: "plugin";
|
||
/** Display name of the instance-level environment row (unique per instance). */
|
||
name: string;
|
||
description?: string;
|
||
/**
|
||
* The serving plugin's manifest id (for example
|
||
* `example.remote-host-plugin`), not its `plugins.autoInstall` catalog key.
|
||
* Boot requires a catalog entry
|
||
* whose `key` is in `plugins.autoInstall` to install this id.
|
||
*/
|
||
pluginKey: string;
|
||
/** The plugin's environment driver key. */
|
||
driverKey: string;
|
||
/** Driver config, validated by the plugin at ensure time; never carries secrets. */
|
||
driverConfig: Readonly<Record<string, unknown>>;
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* One declared managed sandbox environment. Provider-agnostic: `provider` is
|
||
* the sandbox plugin's driver key (== its `plugins.autoInstall` catalog key),
|
||
* and `config` is stored verbatim in the environment row for the plugin to
|
||
* validate at lease time — the same split `ensureKubernetesEnvironment` has
|
||
* always used, generalized to any bundled sandbox provider.
|
||
*/
|
||
export interface ManagedEnvironmentSpec {
|
||
/** Display name of the instance-level environment row (unique per instance). */
|
||
name: string;
|
||
description?: string;
|
||
/** Sandbox provider key (the plugin's driverKey, e.g. "daytona"). */
|
||
provider: string;
|
||
/** Provider config stored in `environment.config`; never carries secrets. */
|
||
config: Readonly<Record<string, unknown>>;
|
||
}
|
||
|
||
/**
|
||
* Managed-config documents deliver NO secrets, ever. Provider credentials
|
||
* reach a managed instance as process environment variables (every bundled
|
||
* sandbox provider falls back to its env var when `config` omits the key,
|
||
* e.g. `DAYTONA_API_KEY`), so any secret-looking config key in the document
|
||
* is a misrouted credential and fails startup. The environments API reuses
|
||
* this pattern to floor credential-shaped config keys out of
|
||
* platform-provisioned environment responses on managed instances.
|
||
*/
|
||
export const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i;
|
||
|
||
const WORKER_ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
||
const COMPANY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
|
||
const WORKER_ENV_DENIED_NAME_PATTERN =
|
||
/(^|_)(AWS|CREDENTIAL|CREDENTIALS|DATABASE|DSN|JWT|KEY|KEYS|PASSWORD|POSTGRES|PRIVATE|SECRET|SECRETS|SIGNING|TOKEN)(_|$)|^(DB|DYLD|LD|NODE)_|^(PATH|PAPERCLIP_MANAGED_CONFIG)$/;
|
||
|
||
function findSecretLikeConfigKey(value: Record<string, unknown>, path: string): string | null {
|
||
for (const [key, child] of Object.entries(value)) {
|
||
const childPath = path.length > 0 ? `${path}.${key}` : key;
|
||
if (SECRET_LIKE_CONFIG_KEY_PATTERN.test(key)) return childPath;
|
||
const nested = findSecretLikeConfigKeyInValue(child, childPath);
|
||
if (nested) return nested;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Objects and arrays at any depth, including arrays nested in arrays. */
|
||
function findSecretLikeConfigKeyInValue(value: unknown, path: string): string | null {
|
||
if (isPlainObject(value)) return findSecretLikeConfigKey(value, path);
|
||
if (!Array.isArray(value)) return null;
|
||
for (const [index, element] of value.entries()) {
|
||
const nested = findSecretLikeConfigKeyInValue(element, `${path}[${index}]`);
|
||
if (nested) return nested;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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)}`;
|
||
}
|
||
|
||
const SANDBOX_ENTRY_KEYS = ["name", "description", "driver", "provider", "config"] as const;
|
||
const PLUGIN_ENTRY_KEYS = [
|
||
"name",
|
||
"description",
|
||
"driver",
|
||
"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._-]*$/;
|
||
|
||
function requireTrimmedString(value: unknown, path: string): string {
|
||
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
||
fail(`"${path}" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(value)})`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readOptionalDescription(value: unknown, path: string): string | undefined {
|
||
if (value === undefined) return undefined;
|
||
if (typeof value !== "string" || value.trim().length === 0) {
|
||
fail(`"${path}" must be a non-empty string when present (got ${describeJsonValue(value)})`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readSecretFreeObject(value: unknown, path: string): Record<string, unknown> {
|
||
if (value === undefined) return {};
|
||
if (!isPlainObject(value)) {
|
||
fail(`"${path}" must be an object (got ${describeJsonValue(value)})`);
|
||
}
|
||
const secretLikeKey = findSecretLikeConfigKey(value, "");
|
||
if (secretLikeKey) {
|
||
fail(
|
||
`"${path}" key "${secretLikeKey}" looks secret-bearing; credentials are delivered to managed instances as process environment variables (the provider's documented env fallback), never in the managed-config document`,
|
||
);
|
||
}
|
||
return { ...value };
|
||
}
|
||
|
||
/**
|
||
* Parse the `environments` section: at most one sandbox entry (no `driver`,
|
||
* or `driver: "sandbox"`) plus any number of `driver: "plugin"` entries.
|
||
* Names are unique across the section, and plugin entries are unique by
|
||
* their `pluginKey`–`driverKey` pair.
|
||
*/
|
||
function parseManagedEnvironmentsSection(
|
||
raw: unknown,
|
||
autoInstall: readonly string[],
|
||
): { sandbox: ManagedEnvironmentSpec[]; plugin: ManagedPluginEnvironmentSpec[] } {
|
||
const sandbox: ManagedEnvironmentSpec[] = [];
|
||
const plugin: ManagedPluginEnvironmentSpec[] = [];
|
||
if (raw === undefined) return { sandbox, plugin };
|
||
if (!Array.isArray(raw)) {
|
||
fail(`"environments" must be an array of environment objects (got ${describeJsonValue(raw)})`);
|
||
}
|
||
const names = new Set<string>();
|
||
const pluginDrivers = new Set<string>();
|
||
let instanceDefaultIndex: number | null = null;
|
||
for (const [index, entry] of raw.entries()) {
|
||
const at = `environments[${index}]`;
|
||
if (!isPlainObject(entry)) {
|
||
fail(`"${at}" must be an object (got ${describeJsonValue(entry)})`);
|
||
}
|
||
// Only an absent `driver` means the sandbox entry; an explicit `null` is malformed.
|
||
const driver = entry.driver === undefined ? "sandbox" : entry.driver;
|
||
if (!(MANAGED_ENVIRONMENT_DRIVERS as readonly unknown[]).includes(driver)) {
|
||
fail(
|
||
`"${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)) {
|
||
fail(`"${at}" has unknown key "${key}" (allowed for a ${driver} entry: ${allowedKeys.join(", ")})`);
|
||
}
|
||
}
|
||
const name = requireTrimmedString(entry.name, `${at}.name`);
|
||
if (names.has(name)) {
|
||
fail(`"${at}.name" duplicates another entry's name "${name}"; environment names are unique per instance`);
|
||
}
|
||
names.add(name);
|
||
const description = readOptionalDescription(entry.description, `${at}.description`);
|
||
|
||
if (driver === "sandbox") {
|
||
// The DB enforces at most ONE Paperclip-managed sandbox row per instance
|
||
// (partial unique index `environments_managed_sandbox_idx`); every
|
||
// sandbox entry provisions that row, so a second one can never be
|
||
// satisfied.
|
||
if (sandbox.length > 0) {
|
||
fail(
|
||
`"environments" supports at most one sandbox entry: each sandbox entry provisions the single Paperclip-managed sandbox environment (DB invariant environments_managed_sandbox_idx)`,
|
||
);
|
||
}
|
||
const provider = requireTrimmedString(entry.provider, `${at}.provider`);
|
||
// Coherence: on a managed instance the control plane is the only plugin
|
||
// install path, so an environment whose provider plugin is not
|
||
// auto-installed could never serve a lease. Catch the skew at parse time.
|
||
if (!autoInstall.includes(provider)) {
|
||
fail(
|
||
`"${at}.provider" is "${provider}", which is not in "plugins.autoInstall"; a managed environment requires its provider plugin to be provisioned`,
|
||
);
|
||
}
|
||
if (isPlainObject(entry.config) && entry.config.provider !== undefined) {
|
||
fail(`"${at}.config" must not set "provider"; it is forced from the entry's provider key`);
|
||
}
|
||
const config = readSecretFreeObject(entry.config, `${at}.config`);
|
||
sandbox.push(
|
||
Object.freeze({
|
||
name,
|
||
...(description !== undefined ? { description } : {}),
|
||
provider,
|
||
config: Object.freeze(config),
|
||
}) as ManagedEnvironmentSpec,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// `pluginKey` is the plugin's manifest id (what the row's config stores),
|
||
// not a `plugins.autoInstall` catalog key, so this pure parser cannot map
|
||
// it. `applyManagedEnvironments` refuses startup when no auto-installed
|
||
// catalog entry installs this manifest id.
|
||
const pluginKey = requireTrimmedString(entry.pluginKey, `${at}.pluginKey`);
|
||
const driverKey = requireTrimmedString(entry.driverKey, `${at}.driverKey`);
|
||
if (!PLUGIN_DRIVER_KEY_PATTERN.test(driverKey)) {
|
||
fail(
|
||
`"${at}.driverKey" must start with a lowercase letter or digit and contain only lowercase letters, digits, dots, hyphens, or underscores (got ${describeJsonValue(driverKey)})`,
|
||
);
|
||
}
|
||
const pair = `${pluginKey}:${driverKey}`;
|
||
if (pluginDrivers.has(pair)) {
|
||
fail(`"${at}" duplicates another plugin entry's pluginKey and driverKey ("${pair}"); each plugin environment driver is declared once`);
|
||
}
|
||
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",
|
||
name,
|
||
...(description !== undefined ? { description } : {}),
|
||
pluginKey,
|
||
driverKey,
|
||
driverConfig: Object.freeze(driverConfig),
|
||
instanceDefault,
|
||
}) as ManagedPluginEnvironmentSpec,
|
||
);
|
||
}
|
||
return { sandbox, plugin };
|
||
}
|
||
|
||
/**
|
||
* 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", "environments"]);
|
||
for (const key of Object.keys(doc)) {
|
||
if (!allowedTopLevelKeys.has(key)) {
|
||
fail(`has unknown top-level key "${key}" (allowed: v, mode, catalogVersion, features, plugins, environments)`);
|
||
}
|
||
}
|
||
|
||
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 (key === "enablePipelines" && typeof value === "boolean") continue;
|
||
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" && key !== "workerEnv" && key !== "enabledCompanies") {
|
||
fail(`"plugins" has unknown key "${key}" (allowed: autoInstall, workerEnv, enabledCompanies)`);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
|
||
let workerEnv: Record<string, readonly string[]> | undefined;
|
||
if (doc.plugins.workerEnv !== undefined) {
|
||
if (!isPlainObject(doc.plugins.workerEnv)) {
|
||
fail(`"plugins.workerEnv" must be an object mapping plugin key → variable names (got ${describeJsonValue(doc.plugins.workerEnv)})`);
|
||
}
|
||
workerEnv = Object.create(null) as Record<string, readonly string[]>;
|
||
for (const [key, names] of Object.entries(doc.plugins.workerEnv)) {
|
||
if (!autoInstall.includes(key)) {
|
||
fail(`"plugins.workerEnv" key "${key}" is not in "plugins.autoInstall"; a grant requires its plugin to be provisioned`);
|
||
}
|
||
if (!Array.isArray(names) || names.length === 0) {
|
||
fail(`"plugins.workerEnv.${key}" must be a non-empty array of variable names (got ${describeJsonValue(names)})`);
|
||
}
|
||
const granted: string[] = [];
|
||
for (const name of names) {
|
||
if (typeof name !== "string" || !WORKER_ENV_NAME_PATTERN.test(name)) {
|
||
fail(`"plugins.workerEnv.${key}" entries must be uppercase variable names (got ${describeJsonValue(name)})`);
|
||
}
|
||
if (WORKER_ENV_DENIED_NAME_PATTERN.test(name)) {
|
||
fail(`"plugins.workerEnv.${key}" must not grant host credential or loader variable "${name}"`);
|
||
}
|
||
if (granted.includes(name)) {
|
||
fail(`"plugins.workerEnv.${key}" has duplicate entry "${name}"`);
|
||
}
|
||
granted.push(name);
|
||
}
|
||
workerEnv[key] = Object.freeze(granted);
|
||
}
|
||
}
|
||
|
||
let enabledCompanies: Record<string, readonly string[]> | undefined;
|
||
if (doc.plugins.enabledCompanies !== undefined) {
|
||
if (!isPlainObject(doc.plugins.enabledCompanies)) {
|
||
fail(`"plugins.enabledCompanies" must be an object mapping plugin key → company ids (got ${describeJsonValue(doc.plugins.enabledCompanies)})`);
|
||
}
|
||
enabledCompanies = Object.create(null) as Record<string, readonly string[]>;
|
||
for (const [key, ids] of Object.entries(doc.plugins.enabledCompanies)) {
|
||
if (!autoInstall.includes(key)) {
|
||
fail(`"plugins.enabledCompanies" key "${key}" is not in "plugins.autoInstall"; enabling a company requires its plugin to be provisioned`);
|
||
}
|
||
if (!Array.isArray(ids) || ids.length === 0) {
|
||
fail(`"plugins.enabledCompanies.${key}" must be a non-empty array of company ids (got ${describeJsonValue(ids)})`);
|
||
}
|
||
const companyIds: string[] = [];
|
||
for (const id of ids) {
|
||
if (typeof id !== "string" || !COMPANY_ID_PATTERN.test(id)) {
|
||
fail(`"plugins.enabledCompanies.${key}" entries must be lowercase company UUIDs (got ${describeJsonValue(id)})`);
|
||
}
|
||
if (companyIds.includes(id)) {
|
||
fail(`"plugins.enabledCompanies.${key}" has duplicate entry "${id}"`);
|
||
}
|
||
companyIds.push(id);
|
||
}
|
||
enabledCompanies[key] = Object.freeze(companyIds);
|
||
}
|
||
}
|
||
|
||
// `environments` is OPTIONAL, unlike `features` and `plugins`: documents
|
||
// delivered before the section existed must keep booting newer builds (a
|
||
// fleet image roll cannot be lockstepped with a config re-delivery), and
|
||
// absence is not a dropped security control — it simply declares no managed
|
||
// environments. When present, the section is validated fail-closed like
|
||
// everything else.
|
||
const { sandbox: environmentSpecs, plugin: pluginEnvironmentSpecs } = parseManagedEnvironmentsSection(
|
||
doc.environments,
|
||
autoInstall,
|
||
);
|
||
|
||
return Object.freeze({
|
||
v: SUPPORTED_MANAGED_CONFIG_VERSION,
|
||
mode: "cloud",
|
||
catalogVersion: doc.catalogVersion,
|
||
features: Object.freeze(features),
|
||
plugins: Object.freeze({
|
||
autoInstall: Object.freeze(autoInstall),
|
||
...(workerEnv ? { workerEnv: Object.freeze(workerEnv) } : {}),
|
||
...(enabledCompanies ? { enabledCompanies: Object.freeze(enabledCompanies) } : {}),
|
||
}),
|
||
environments: Object.freeze(environmentSpecs),
|
||
pluginEnvironments: Object.freeze(pluginEnvironmentSpecs),
|
||
}) 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;
|
||
}
|