mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +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>
430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MANAGED_CONFIG_ENV_KEY,
|
|
getManagedInstanceConfig,
|
|
managedFeatureKeySet,
|
|
parseManagedConfigEnv,
|
|
} from "../services/managed-config.js";
|
|
|
|
function envWith(raw: string | undefined) {
|
|
return { [MANAGED_CONFIG_ENV_KEY]: raw };
|
|
}
|
|
|
|
function validDoc(overrides: Record<string, unknown> = {}) {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enableStatusCards: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("plugins.workerEnv", () => {
|
|
const plugins = (workerEnv: unknown) => ({ plugins: { autoInstall: ["daytona"], workerEnv } });
|
|
|
|
it("is absent from documents that do not declare it", () => {
|
|
expect(parseManagedConfigEnv(envWith(validDoc()))?.plugins).toEqual({ autoInstall: ["daytona", "kubernetes"] });
|
|
});
|
|
|
|
it("accepts variable names for an auto-installed plugin", () => {
|
|
const parsed = parseManagedConfigEnv(envWith(validDoc(plugins({ daytona: ["PROVIDER_ORIGIN", "PROVIDER_CAPABILITY_2"] }))));
|
|
expect(parsed?.plugins.workerEnv).toEqual({ daytona: ["PROVIDER_ORIGIN", "PROVIDER_CAPABILITY_2"] });
|
|
});
|
|
|
|
it.each([
|
|
"ANTHROPIC_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "BETTER_AUTH_SECRET", "DATABASE_MIGRATION_URL", "DATABASE_URL", "DB_PASSWORD",
|
|
"GH_TOKEN", "GITHUB_TOKEN", "LD_PRELOAD", "NODE_OPTIONS", "PAPERCLIP_AGENT_JWT_SECRET", "PAPERCLIP_AUTH_SECRET", "PAPERCLIP_BACKUP_WORKER_DATABASE_URL",
|
|
"PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", "PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "PAPERCLIP_DECISION_SIGNING_SECRET", "PAPERCLIP_MANAGED_CONFIG",
|
|
"PAPERCLIP_SECRETS_MASTER_KEY", "PAPERCLIP_SECRETS_MASTER_KEY_FILE", "PAPERCLIP_TEST_POSTGRES_ADMIN_URL", "PATH", "SENTRY_DSN",
|
|
])("refuses to start with a grant of %s", (name) => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc(plugins({ daytona: [name] }))))).toThrow(`must not grant host credential or loader variable "${name}"`);
|
|
});
|
|
|
|
it.each(["PAPERCLIP_CLOUD_API_ORIGIN", "PAPERCLIP_PRIMARY_HOST", "PROVIDER_CAPABILITY", "MONKEY_ORIGIN"])("accepts a grant of %s", (name) => {
|
|
expect(parseManagedConfigEnv(envWith(validDoc(plugins({ daytona: [name] }))))?.plugins.workerEnv).toEqual({ daytona: [name] });
|
|
});
|
|
|
|
it.each([
|
|
["a plugin that is not auto-installed", { other: ["PROVIDER_ORIGIN"] }, /not in "plugins.autoInstall"/],
|
|
["a non-object section", ["PROVIDER_ORIGIN"], /must be an object/],
|
|
["an empty grant", { daytona: [] }, /non-empty array/],
|
|
["a value instead of a name", { daytona: ["https://example.test"] }, /uppercase variable names/],
|
|
["a lowercase name", { daytona: ["database_url"] }, /uppercase variable names/],
|
|
["a duplicate name", { daytona: ["PROVIDER_ORIGIN", "PROVIDER_ORIGIN"] }, /duplicate entry/],
|
|
])("refuses to start with %s", (_label, workerEnv, message) => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc(plugins(workerEnv))))).toThrow(message);
|
|
});
|
|
});
|
|
|
|
describe("plugins.enabledCompanies", () => {
|
|
const companyId = "f0679247-e2be-5a76-adb6-6bd360e5a833";
|
|
const plugins = (enabledCompanies: unknown) => ({ plugins: { autoInstall: ["daytona"], enabledCompanies } });
|
|
|
|
it("accepts company ids for an auto-installed plugin", () => {
|
|
expect(parseManagedConfigEnv(envWith(validDoc(plugins({ daytona: [companyId] }))))?.plugins.enabledCompanies).toEqual({ daytona: [companyId] });
|
|
});
|
|
|
|
it.each([
|
|
["a plugin that is not auto-installed", { other: [companyId] }, /not in "plugins.autoInstall"/],
|
|
["a non-object section", [companyId], /must be an object/],
|
|
["an empty list", { daytona: [] }, /non-empty array/],
|
|
["a value that is not a UUID", { daytona: ["all"] }, /lowercase company UUIDs/],
|
|
["an uppercase UUID", { daytona: [companyId.toUpperCase()] }, /lowercase company UUIDs/],
|
|
["a duplicate id", { daytona: [companyId, companyId] }, /duplicate entry/],
|
|
])("refuses to start with %s", (_label, enabledCompanies, message) => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc(plugins(enabledCompanies))))).toThrow(message);
|
|
});
|
|
});
|
|
|
|
describe("managedFeatureKeySet", () => {
|
|
it("contains exactly the boolean flags of the experimental schema", () => {
|
|
const keys = managedFeatureKeySet();
|
|
expect(keys.has("enableApps")).toBe(true);
|
|
expect(keys.has("enableWorktreeRunExecution")).toBe(true);
|
|
// Server-managed bookkeeping fields are not overlayable features.
|
|
expect(keys.has("worktreeRunExecutionActivatedAt")).toBe(false);
|
|
expect(keys.has("worktreeRunExecutionActivationInstanceId")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv", () => {
|
|
it("returns null when the env var is absent (self-hosted)", () => {
|
|
expect(parseManagedConfigEnv({})).toBeNull();
|
|
expect(parseManagedConfigEnv(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("throws when the env var is present but blank (fail closed)", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(""))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith(" "))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith("\n\t"))).toThrow(/is set but blank/);
|
|
});
|
|
|
|
it("parses a complete valid document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enableStatusCards: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("accepts the deprecated runner ingress key for managed-config compatibility", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableRunnerPreviewIngress: true } })),
|
|
);
|
|
|
|
expect(config?.features).toEqual({ enableRunnerPreviewIngress: true });
|
|
});
|
|
|
|
it("accepts empty features {} and autoInstall [] sections", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: {}, plugins: { autoInstall: [] } })),
|
|
);
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
plugins: { autoInstall: [] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("throws when the features section is missing (fail closed)", () => {
|
|
const doc = { v: 1, mode: "cloud", catalogVersion: "2026.720.0", plugins: { autoInstall: [] } };
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(doc)))).toThrow(
|
|
/requires a "features" object/,
|
|
);
|
|
});
|
|
|
|
it("throws when the plugins section or autoInstall is missing (fail closed)", () => {
|
|
const noPlugins = {
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
};
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(noPlugins)))).toThrow(
|
|
/requires a "plugins" object/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: {} })))).toThrow(
|
|
/requires a "plugins.autoInstall" array/,
|
|
);
|
|
});
|
|
|
|
it("returns a frozen document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(Object.isFrozen(config)).toBe(true);
|
|
expect(Object.isFrozen(config?.features)).toBe(true);
|
|
expect(Object.isFrozen(config?.plugins.autoInstall)).toBe(true);
|
|
});
|
|
|
|
it("throws on invalid JSON", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("{not json"))).toThrow(
|
|
/PAPERCLIP_MANAGED_CONFIG is not valid JSON/,
|
|
);
|
|
});
|
|
|
|
it("throws on non-object documents", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("[]"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("42"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("null"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith('"cloud"'))).toThrow(/must be a JSON object/);
|
|
});
|
|
|
|
it("throws on an unknown top-level key", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ extra: true })))).toThrow(
|
|
/unknown top-level key "extra"/,
|
|
);
|
|
});
|
|
|
|
it("throws on an unsupported v", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: 2 })))).toThrow(
|
|
/unsupported "v" 2; this build supports v=1/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: "1" })))).toThrow(/unsupported "v"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(JSON.stringify({ mode: "cloud", catalogVersion: "x" })),
|
|
),
|
|
).toThrow(/unsupported "v"/);
|
|
});
|
|
|
|
it("throws on a non-cloud mode", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ mode: "self-hosted" })))).toThrow(
|
|
/invalid "mode" "self-hosted"; expected "cloud"/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, catalogVersion: "x" }))),
|
|
).toThrow(/invalid "mode"/);
|
|
});
|
|
|
|
it("throws on a missing or empty catalogVersion", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, mode: "cloud" }))),
|
|
).toThrow(/non-empty string "catalogVersion"/);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: "" })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: 7 })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
});
|
|
|
|
it("throws on a non-object features section", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ features: ["enableApps"] })))).toThrow(
|
|
/"features" must be an object/,
|
|
);
|
|
});
|
|
|
|
it.each([true, false])("ignores the retired Pipelines flag (%s) from older Cloud catalogs", (enabled) => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc({
|
|
features: { enablePipelines: enabled, enableCases: true },
|
|
})));
|
|
expect(config?.features).toEqual({ enableCases: true });
|
|
expect(managedFeatureKeySet().has("enablePipelines")).toBe(false);
|
|
});
|
|
|
|
it("throws on an unknown feature key", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableTimeTravel: true } }))),
|
|
).toThrow(/unknown feature key "enableTimeTravel"/);
|
|
// A server-managed bookkeeping field is not an overlayable feature.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { worktreeRunExecutionActivatedAt: true } })),
|
|
),
|
|
).toThrow(/unknown feature key "worktreeRunExecutionActivatedAt"/);
|
|
});
|
|
|
|
it("throws on a feature key the catalog does not mark tier \"managed\"", () => {
|
|
// `enableStreamlinedLeftNavigation` is a real schema flag, but its catalog
|
|
// tier is `preference` (tenant-controllable) — a managed-config document
|
|
// targeting it has incompatible catalog semantics and must fail closed.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableStreamlinedLeftNavigation: true } })),
|
|
),
|
|
).toThrow(
|
|
/"features" key "enableStreamlinedLeftNavigation" has tier "preference".*only tier "managed" keys/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableDecisions: false } }))),
|
|
).toThrow(/has tier "preference"/);
|
|
});
|
|
|
|
it("throws on non-boolean feature values", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: "true" } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: 1 } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: null } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
});
|
|
|
|
it("throws on malformed plugins sections", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: [] })))).toThrow(
|
|
/"plugins" must be an object/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { install: [] } }))),
|
|
).toThrow(/"plugins" has unknown key "install"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: "daytona" } }))),
|
|
).toThrow(/"plugins.autoInstall" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [""] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [" daytona"] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [42] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ plugins: { autoInstall: ["daytona", "daytona"] } })),
|
|
),
|
|
).toThrow(/duplicate entry "daytona"/);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv environments section", () => {
|
|
const entry = (overrides: Record<string, unknown> = {}) => ({
|
|
name: "Daytona",
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
...overrides,
|
|
});
|
|
|
|
it("defaults to an empty list when the section is absent (pre-section documents keep booting)", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config?.environments).toEqual([]);
|
|
});
|
|
|
|
it("parses a declared environment and freezes it", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ description: "Managed Daytona sandbox." })] })),
|
|
);
|
|
expect(config?.environments).toHaveLength(1);
|
|
const spec = config?.environments[0];
|
|
expect(spec?.name).toBe("Daytona");
|
|
expect(spec?.description).toBe("Managed Daytona sandbox.");
|
|
expect(spec?.provider).toBe("daytona");
|
|
expect(spec?.config).toEqual({ target: "us" });
|
|
expect(Object.isFrozen(config?.environments)).toBe(true);
|
|
expect(Object.isFrozen(spec)).toBe(true);
|
|
expect(Object.isFrozen(spec?.config)).toBe(true);
|
|
});
|
|
|
|
it("treats config and description as optional", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [{ name: "Daytona", provider: "daytona" }] })),
|
|
);
|
|
expect(config?.environments[0]?.config).toEqual({});
|
|
expect(config?.environments[0]?.description).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a non-array section and non-object entries", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: {} }))),
|
|
).toThrow(/"environments" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: ["daytona"] }))),
|
|
).toThrow(/"environments\[0\]" must be an object/);
|
|
});
|
|
|
|
it("rejects more than one entry (single managed sandbox slot)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry(), entry({ name: "Other", provider: "kubernetes" })] })),
|
|
),
|
|
).toThrow(/at most one entry/);
|
|
});
|
|
|
|
it("rejects unknown entry keys", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ envVars: {} })] }))),
|
|
).toThrow(/unknown key "envVars"/);
|
|
});
|
|
|
|
it("rejects malformed names, descriptions, and providers", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: "" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: " Daytona" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ description: " " })] }))),
|
|
).toThrow(/"environments\[0\].description"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: 7 })] }))),
|
|
).toThrow(/"environments\[0\].provider"/);
|
|
});
|
|
|
|
it("rejects a provider that plugins.autoInstall does not provision", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: "modal" })] }))),
|
|
).toThrow(/not in "plugins.autoInstall"/);
|
|
});
|
|
|
|
it("rejects a config that sets provider", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { provider: "daytona" } })] })),
|
|
),
|
|
).toThrow(/must not set "provider"/);
|
|
});
|
|
|
|
it("rejects secret-bearing config keys at any depth (secrets travel as env vars)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { apiKey: "not-a-real-key" } })] })),
|
|
),
|
|
).toThrow(/looks secret-bearing/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { auth: { accessToken: "t" } } })] })),
|
|
),
|
|
).toThrow(/auth.accessToken/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(
|
|
validDoc({ environments: [entry({ config: { adapters: [{ clientSecret: "s" }] } })] }),
|
|
),
|
|
),
|
|
).toThrow(/adapters\[0\].clientSecret/);
|
|
});
|
|
});
|
|
|
|
describe("getManagedInstanceConfig", () => {
|
|
it("caches by raw env value and reparses when it changes", () => {
|
|
const raw = validDoc();
|
|
const first = getManagedInstanceConfig(envWith(raw));
|
|
const second = getManagedInstanceConfig(envWith(raw));
|
|
expect(second).toBe(first);
|
|
|
|
const changed = getManagedInstanceConfig(
|
|
envWith(validDoc({ catalogVersion: "2026.721.0" })),
|
|
);
|
|
expect(changed?.catalogVersion).toBe("2026.721.0");
|
|
expect(changed).not.toBe(first);
|
|
|
|
expect(getManagedInstanceConfig(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("rethrows parse failures on every call instead of caching them", () => {
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
});
|
|
});
|