mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +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>
549 lines
24 KiB
TypeScript
549 lines
24 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MANAGED_CONFIG_ENV_KEY,
|
|
MANAGED_ENVIRONMENT_DRIVERS,
|
|
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: [],
|
|
pluginEnvironments: [],
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
pluginEnvironments: [],
|
|
});
|
|
});
|
|
|
|
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 sandbox entry (single managed sandbox slot)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry(), entry({ name: "Other", provider: "kubernetes" })] })),
|
|
),
|
|
).toThrow(/at most one sandbox entry/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry(), entry({ name: "Other", driver: "sandbox", provider: "kubernetes" })] })),
|
|
),
|
|
).toThrow(/at most one sandbox 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/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { pools: [[], [{ privateKey: { password: "s" } }]] } })] })),
|
|
),
|
|
).toThrow(/pools\[1\]\[0\].privateKey.password/);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv plugin environment entries", () => {
|
|
const PLUGIN_KEY = "example.remote-host-plugin";
|
|
const remoteHost = (overrides: Record<string, unknown> = {}) => ({
|
|
name: "Remote Host",
|
|
description: "Runs the agent on a remote host",
|
|
driver: "plugin",
|
|
pluginKey: PLUGIN_KEY,
|
|
driverKey: "tasks",
|
|
driverConfig: {},
|
|
instanceDefault: true,
|
|
...overrides,
|
|
});
|
|
const sandbox = { name: "Managed Sandbox", provider: "daytona", config: { target: "us" } };
|
|
// `plugins.autoInstall` holds catalog keys ("remote-host"); the entry's
|
|
// `pluginKey` is the plugin's manifest id, which boot maps through the catalog.
|
|
const docWith = (environments: unknown[], autoInstall = ["daytona", "remote-host"]) =>
|
|
envWith(validDoc({ plugins: { autoInstall }, environments }));
|
|
|
|
it("lists the drivers an entry may declare", () => {
|
|
expect(MANAGED_ENVIRONMENT_DRIVERS).toEqual(["sandbox", "plugin"]);
|
|
});
|
|
|
|
it("parses an old document's sandbox entry unchanged", () => {
|
|
const config = parseManagedConfigEnv(docWith([sandbox]));
|
|
expect(config?.environments).toEqual([{ name: "Managed Sandbox", provider: "daytona", config: { target: "us" } }]);
|
|
expect(config?.pluginEnvironments).toEqual([]);
|
|
});
|
|
|
|
it("parses the sandbox entry beside a plugin entry", () => {
|
|
const config = parseManagedConfigEnv(docWith([sandbox, remoteHost()]));
|
|
expect(config?.environments).toEqual([{ name: "Managed Sandbox", provider: "daytona", config: { target: "us" } }]);
|
|
expect(config?.pluginEnvironments).toEqual([{
|
|
driver: "plugin",
|
|
name: "Remote Host",
|
|
description: "Runs the agent on a remote host",
|
|
pluginKey: PLUGIN_KEY,
|
|
driverKey: "tasks",
|
|
driverConfig: {},
|
|
instanceDefault: true,
|
|
}]);
|
|
expect(Object.isFrozen(config?.pluginEnvironments)).toBe(true);
|
|
expect(Object.isFrozen(config?.pluginEnvironments[0])).toBe(true);
|
|
});
|
|
|
|
it("parses a plugin entry alone, with optional fields omitted", () => {
|
|
const config = parseManagedConfigEnv(docWith([{ name: "Remote", driver: "plugin", pluginKey: PLUGIN_KEY, driverKey: "tasks" }]));
|
|
expect(config?.environments).toEqual([]);
|
|
expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {}, instanceDefault: false });
|
|
expect(config?.pluginEnvironments[0]?.description).toBeUndefined();
|
|
});
|
|
|
|
it("does not compare the manifest id with autoInstall catalog keys (boot checks the catalog)", () => {
|
|
expect(parseManagedConfigEnv(docWith([remoteHost()]))?.pluginEnvironments[0]?.pluginKey).toBe(PLUGIN_KEY);
|
|
});
|
|
|
|
it("refuses duplicate names, including across drivers", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([sandbox, remoteHost({ name: "Managed Sandbox" })])))
|
|
.toThrow(/"environments\[1\].name" duplicates another entry's name/);
|
|
});
|
|
|
|
it("refuses a duplicate pluginKey and driverKey pair", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", instanceDefault: false })])))
|
|
.toThrow(/duplicates another plugin entry's pluginKey and driverKey/);
|
|
});
|
|
|
|
it("refuses instanceDefault on two entries", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", driverKey: "other" })])))
|
|
.toThrow(/at most one entry may become the instance default/);
|
|
});
|
|
|
|
it("refuses instanceDefault on the sandbox entry, even false", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: true }])))
|
|
.toThrow(/"environments\[0\].instanceDefault" is not allowed on the sandbox entry/);
|
|
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }])))
|
|
.toThrow(/not allowed on the sandbox entry/);
|
|
});
|
|
|
|
it("refuses a non-boolean instanceDefault", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: "yes" })])))
|
|
.toThrow(/"environments\[0\].instanceDefault" must be a boolean/);
|
|
});
|
|
|
|
it("refuses secret-like driverConfig keys at any depth", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { apiToken: "t" } })])))
|
|
.toThrow(/"environments\[0\].driverConfig" key "apiToken" looks secret-bearing/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { auth: [{ password: "p" }] } })])))
|
|
.toThrow(/auth\[0\].password/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { groups: [[{ apiToken: "t" }]] } })])))
|
|
.toThrow(/groups\[0\]\[0\].apiToken/);
|
|
});
|
|
|
|
it("refuses unknown keys, including sandbox keys on a plugin entry and the reverse", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ envVars: {} })]))).toThrow(/unknown key "envVars"/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ provider: "daytona" })]))).toThrow(/unknown key "provider"/);
|
|
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, pluginKey: PLUGIN_KEY }]))).toThrow(/unknown key "pluginKey"/);
|
|
});
|
|
|
|
it("refuses an unknown driver and malformed plugin fields", () => {
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driver: "ssh" })]))).toThrow(/"environments\[0\].driver" must be one of "sandbox", "plugin"/);
|
|
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, driver: null }]))).toThrow(/"environments\[0\].driver" must be one of "sandbox", "plugin" when present \(got null\)/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverKey: "Tasks" })]))).toThrow(/"environments\[0\].driverKey" must start/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverKey: undefined })]))).toThrow(/"environments\[0\].driverKey" must be a non-empty string/);
|
|
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: [] })]))).toThrow(/"environments\[0\].driverConfig" must be an object/);
|
|
});
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|