Files
PaperClipAI/server/src/__tests__/managed-config.test.ts
T
Devin Foley 273315a4d0 feat(server): provision managed sandbox environments from the managed config (#10324)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Hosted/managed deployments configure instances entirely from the
control plane: `PAPERCLIP_MANAGED_CONFIG` already delivers feature flags
and `plugins.autoInstall` (bundled sandbox provider plugins), parsed
fail-closed at boot
> - A plugin alone is not usable for execution: runs need an
instance-level `driver: "sandbox"` environment row pointing at the
provider, and today only Kubernetes has a boot path for that
(`PAPERCLIP_EXECUTION_MODE` → `ensureKubernetesEnvironment`); every
other provider requires a manual product-API call the control plane
cannot make on a managed instance
> - Adding one `ensureXxxEnvironment` per provider would multiply
near-identical boot hooks and env-var surfaces
> - This pull request generalizes the existing Kubernetes machinery: the
managed-config document gains an optional `environments` section that
declares a sandbox environment for any bundled provider, ensured
idempotently at boot by a provider-agnostic service function (the
Kubernetes hook becomes a thin wrapper over it)
> - The benefit is that a managed fleet can provision any sandbox
provider (Daytona, Modal, E2B, …) purely from configuration — no
per-provider code, no manual API calls, no secrets in the document —
while self-hosted behavior is untouched

## Linked Issues or Issue Description

No public issue exists. Refs #10157 (the cloud image variant that
bundles sandbox provider plugins — this PR is the configuration half
that makes an installed provider usable).

**Problem (feature-request shape):** on a managed instance the control
plane can auto-install a bundled sandbox provider plugin via
`PAPERCLIP_MANAGED_CONFIG.plugins.autoInstall`, but cannot create the
environment row that makes the provider schedulable. The only boot-time
environment provisioning is Kubernetes-specific
(`PAPERCLIP_EXECUTION_MODE=kubernetes` + `PAPERCLIP_K8S_*`). A generic,
config-driven path is needed so any bundled provider can be provisioned
without per-plugin code or manual API calls.

## What Changed

- `server/src/services/managed-config.ts`: optional `environments`
top-level section — `[{ name, description?, provider, config? }]` —
validated fail-closed: unknown keys, more than one entry (the DB permits
exactly one Paperclip-managed sandbox row,
`environments_managed_sandbox_idx`), a `provider` not present in
`plugins.autoInstall`, `config.provider`, or secret-looking config keys
at any depth (`api_key`/`token`/`secret`/`password`/`credential`) all
refuse startup. Absent section ⇒ `environments: []`, so pre-section
documents keep booting newer builds.
- `server/src/services/environments.ts`: new provider-agnostic
`ensureManagedSandboxEnvironment({ name, description?, provider,
config?, extraMetadata? })` — idempotently owns the single managed
sandbox row: refreshes name/description/config each call, adopts the
slot across provider switches (dropping the stale
`managedKubernetesSandbox` marker), adopts a same-name unmanaged sandbox
row (stamping it managed) instead of colliding on
`environments_name_idx` every boot, and falls back to keeping the
current name if the desired name belongs to a different row.
`ensureKubernetesEnvironment` is now a thin wrapper that pins `provider:
"kubernetes"` and stamps the legacy marker.
- `server/src/services/managed-environments.ts` (new):
`applyManagedEnvironments` boot step — no-op for self-hosted/empty;
throws (fail startup) when `PAPERCLIP_EXECUTION_MODE` is also set, since
both would own the same managed sandbox row; otherwise ensures each
declared environment fail-safe per entry (log + continue boot, matching
bundled-plugin provisioning posture).
- `server/src/index.ts`: runs the new boot step right after the
execution-policy bootstrap, before the heartbeat resumes queued runs.
- `server/src/services/index.ts`: exports `applyManagedEnvironments` and
`ManagedEnvironmentSpec`.
- Secrets stay out of the document by construction: provider credentials
reach managed instances only as process env vars (each provider's
documented fallback, e.g. `DAYTONA_API_KEY` for the Daytona plugin).

## Verification

```sh
cd server
pnpm exec tsc --noEmit -p tsconfig.json
pnpm exec vitest run \
  src/__tests__/managed-config.test.ts \
  src/services/managed-environments.test.ts \
  src/services/execution-policy-bootstrap.test.ts \
  src/__tests__/environment-service.test.ts \
  src/__tests__/environment-instance-routes.test.ts \
  src/__tests__/environment-routes.test.ts \
  src/__tests__/plugin-install-guard.test.ts \
  src/__tests__/environment-execution-target.test.ts \
  src/__tests__/instance-settings-managed-overlay.test.ts \
  src/__tests__/bundled-plugins.test.ts
```

All pass locally (typecheck clean; environment-service suite runs
against embedded Postgres and exercises the refactored Kubernetes
wrapper plus the new generic ensure: create/refresh, provider switch,
unmanaged-row adoption, name-conflict fallback). New tests cover the
parser (12 cases incl. secret-key rejection at depth) and the boot step
(no-op, mutual exclusion, pass-through, fail-safe).

## Risks

- **Self-hosted: none intended.** Without `PAPERCLIP_MANAGED_CONFIG`
nothing new executes; the `PAPERCLIP_EXECUTION_MODE=kubernetes` path is
regression-covered by the existing bootstrap/service/route suites (all
green).
- **Behavioral shift in `ensureKubernetesEnvironment` (deliberate):** it
now also refreshes `name`/`description` to their managed defaults each
boot (desired-state semantics, same as config today) and adopts a
`managedByPaperclip` sandbox row that lacks the Kubernetes marker —
previously that state made the ensure throw every boot.
- **New startup failure modes are all explicit misconfigurations**
(malformed section, provider not auto-installed, secret in config,
execution-mode conflict) and fail with precise errors; DB-side ensure
failures never block boot (fail-safe per entry, logged).
- No migrations; no API surface changes.

## Model Used

Claude Fable 5 (Anthropic, model ID `claude-fable-5`) with extended
thinking and tool use, driving the change end-to-end inside a Claude
Code / agent-harness session (code, tests, and verification 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 (the
managed-config module header is the contract doc)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-07-27 12:00:21 -07:00

358 lines
13 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, enablePipelines: true },
plugins: { autoInstall: ["daytona", "kubernetes"] },
...overrides,
});
}
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);
expect(keys.has("issueGraphLivenessAutoRecoveryLookbackHours")).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, enablePipelines: true },
plugins: { autoInstall: ["daytona", "kubernetes"] },
environments: [],
});
});
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("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/);
});
});