mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its plugins from the control plane, and people do not install them > - Some of these plugins need deployment values, such as a service origin and a capability > - The plugin worker environment is scrubbed, and only a fixed list of first-party providers gets deployment variables > - Core also sends lifecycle events and project instance secrets only to plugins with a saved config row > - So a provisioned plugin cannot work until a person saves configuration for it > - This pull request lets the managed config grant named variables to a selected plugin > - The benefit is that a distribution plugin can run with no tenant configuration, and Core does not need to name it ## Linked Issues or Issue Description No issue exists. This describes the problem. I searched open pull requests for related work and found no duplicate. **What existing behavior does this improve?** How a plugin provisioned through `plugins.autoInstall` receives its deployment settings. **Current behavior** `buildPluginWorkerEnv` forwards variables only from a fixed list that names first-party packages. A distribution plugin cannot be on that list without Core naming it. A plugin with no `pluginConfig` row gets no resource lifecycle events, and project instance secrets refuse it. **Proposed behavior** The managed config can include `plugins.workerEnv`. It maps a catalog key to variable names. The worker of that plugin receives those variables from the server process environment. The managed config can also include `plugins.enabledCompanies`. It maps a catalog key to company IDs. In those companies, the plugin gets lifecycle events and can use project instance secrets with no config row. **Reason and benefit** The control plane stays the only place that decides which plugin gets which values. Tenants do not handle these values, and they are not in the database or in the managed config document. **Alternatives considered** - Create a config or enablement row for each company. The control plane would need a new tenant write API, and a row can stay after the document removes access or return after a database restore. The document is never saved, like the other managed settings. - Give a grant access to every company. This has no per-company opt-in. - Put the values in the managed config document. The document does not hold secrets, and this rule stays true because it holds only names. ## What Changed - `parseManagedConfigEnv` accepts an optional `plugins.workerEnv` object. Each key must be in `plugins.autoInstall`. Names must be uppercase variable names, with no duplicates. - Startup fails if a name looks like a host credential, such as a name with `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DATABASE` or `JWT` as a word, or if it controls how the worker loads code, such as `NODE_OPTIONS`. - `parseManagedConfigEnv` accepts an optional `plugins.enabledCompanies` object. Each key must be in `plugins.autoInstall`. Values must be lowercase company UUIDs, with no duplicates. - `managedPluginWorkerEnvGrant` and `managedPluginEnabledCompanies` return values by plugin key. A managed instance only starts a selected plugin from the current image, so the saved package path is not part of this check. It can be old or null. - `buildPluginWorkerEnv` forwards granted variables. This does not depend on the `environment.drivers.register` capability. - Plugin lifecycle delivery includes a plugin with no config row only for the companies the document names. It still skips a company that disabled the plugin. The runtime event listener follows the same rule. - Project instance secrets accept a plugin with no config row only for a company the document names. - A variable grant alone gives no company access. - `doc/plugins/DISTRIBUTION-PLUGINS.md` describes both sections. ## Verification - `npx vitest run` in `server/` on `managed-config`, `managed-plugin-worker-env`, `plugin-lifecycle-delivery`, `project-instance-secrets`, `plugin-database` and `bundled-plugins`: 201 tests pass. - The new tests cover a digest-verified distribution plugin, a stale saved package path, a catalog key named after an inherited property, a company the document does not name, a company that disabled the plugin, a named company that does not exist, a grant with no enabled company, and host credential names. - `tsc --noEmit` in `server/` shows no errors in the changed files. My worktree does not have the built `@paperclipai/paperclip-runner` package, so unrelated files show errors locally. CI runs the full typecheck. - I did not run the full test suite locally. CI runs it. - I did not test this change on a running instance. ## Risks - **Older builds:** a build without this change refuses a document with `plugins.workerEnv` or `plugins.enabledCompanies`, because it fails closed on unknown keys. A control plane must send these sections only to a build that supports them. - **Trust:** the granted plugin gets the variable values in its process. The image owner and the control plane already trust this plugin. The name check stops a mistake with host credentials, but a name check cannot prove that a value is not sensitive. Core cannot use an allowlist, because it must not name the variables of a private plugin. - **Company scope:** the control plane sets which companies a plugin serves. A new company needs a new document, and the server reads the document when it starts. Self-hosted instances do not change. - **Rotation:** a new value reaches the worker only when the server restarts. Rotation without a restart is not part of this change. - **Background calls:** a grant does not add proactive company access. A plugin that makes background calls with no invocation still needs a saved config for that company. - This changes core plugin behavior, and I did not discuss it in `#dev` first. Maintainers can tell me if they want a discussion before review. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool use and code execution. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
19 KiB
TypeScript
427 lines
19 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"] },
|
|
* "environments": [
|
|
* { "name": "Daytona", "provider": "daytona", "config": { "target": "us" } }
|
|
* ]
|
|
* }
|
|
*
|
|
* 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[]>>;
|
|
};
|
|
/** Sandbox environments the control plane provisions at boot (empty when the section is absent). */
|
|
environments: readonly ManagedEnvironmentSpec[];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (isPlainObject(child)) {
|
|
const nested = findSecretLikeConfigKey(child, childPath);
|
|
if (nested) return nested;
|
|
} else if (Array.isArray(child)) {
|
|
for (const [index, element] of child.entries()) {
|
|
if (!isPlainObject(element)) continue;
|
|
const nested = findSecretLikeConfigKey(element, `${childPath}[${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)}`;
|
|
}
|
|
|
|
/**
|
|
* 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 environmentSpecs: ManagedEnvironmentSpec[] = [];
|
|
if (doc.environments !== undefined) {
|
|
if (!Array.isArray(doc.environments)) {
|
|
fail(`"environments" must be an array of environment objects (got ${describeJsonValue(doc.environments)})`);
|
|
}
|
|
// The DB enforces at most ONE Paperclip-managed sandbox row per instance
|
|
// (partial unique index `environments_managed_sandbox_idx`); every entry
|
|
// here provisions that row, so a longer list can never be satisfied.
|
|
if (doc.environments.length > 1) {
|
|
fail(
|
|
`"environments" supports at most one entry: each entry provisions the single Paperclip-managed sandbox environment (DB invariant environments_managed_sandbox_idx)`,
|
|
);
|
|
}
|
|
for (const [index, entry] of doc.environments.entries()) {
|
|
if (!isPlainObject(entry)) {
|
|
fail(`"environments[${index}]" must be an object (got ${describeJsonValue(entry)})`);
|
|
}
|
|
const allowedEntryKeys = new Set(["name", "description", "provider", "config"]);
|
|
for (const key of Object.keys(entry)) {
|
|
if (!allowedEntryKeys.has(key)) {
|
|
fail(`"environments[${index}]" has unknown key "${key}" (allowed: name, description, provider, config)`);
|
|
}
|
|
}
|
|
if (typeof entry.name !== "string" || entry.name.length === 0 || entry.name.trim() !== entry.name) {
|
|
fail(
|
|
`"environments[${index}].name" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.name)})`,
|
|
);
|
|
}
|
|
if (
|
|
entry.description !== undefined
|
|
&& (typeof entry.description !== "string" || entry.description.trim().length === 0)
|
|
) {
|
|
fail(`"environments[${index}].description" must be a non-empty string when present (got ${describeJsonValue(entry.description)})`);
|
|
}
|
|
if (typeof entry.provider !== "string" || entry.provider.length === 0 || entry.provider.trim() !== entry.provider) {
|
|
fail(
|
|
`"environments[${index}].provider" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.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(entry.provider)) {
|
|
fail(
|
|
`"environments[${index}].provider" is "${entry.provider}", which is not in "plugins.autoInstall"; a managed environment requires its provider plugin to be provisioned`,
|
|
);
|
|
}
|
|
const config: Record<string, unknown> = {};
|
|
if (entry.config !== undefined) {
|
|
if (!isPlainObject(entry.config)) {
|
|
fail(`"environments[${index}].config" must be an object (got ${describeJsonValue(entry.config)})`);
|
|
}
|
|
if (entry.config.provider !== undefined) {
|
|
fail(`"environments[${index}].config" must not set "provider"; it is forced from the entry's provider key`);
|
|
}
|
|
const secretLikeKey = findSecretLikeConfigKey(entry.config, "");
|
|
if (secretLikeKey) {
|
|
fail(
|
|
`"environments[${index}].config" 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`,
|
|
);
|
|
}
|
|
Object.assign(config, entry.config);
|
|
}
|
|
environmentSpecs.push(
|
|
Object.freeze({
|
|
name: entry.name,
|
|
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
provider: entry.provider,
|
|
config: Object.freeze(config),
|
|
}) as ManagedEnvironmentSpec,
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
}) 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;
|
|
}
|