Files
PaperClipAI/server/src/services/plugin-environment-driver.ts
T
Nicky LeachandClaude Opus 5.5 0b73c805e8 feat(environments): fill an unset instance default with a managed plugin environment (#19551)
## 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>
2026-11-06 16:48:13 -08:00

775 lines
30 KiB
TypeScript

import { agents, plugins, type Db } from "@paperclipai/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { conflict } from "../errors.js";
import type {
EnvironmentProbeResult,
PluginEnvironmentConfig,
PluginEnvironmentDriverDeclaration,
} from "@paperclipai/shared";
import type {
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentInteractiveSetupSession,
PluginEnvironmentStartInteractiveSetupParams,
PluginEnvironmentGetInteractiveSetupParams,
PluginEnvironmentCaptureTemplateParams,
PluginEnvironmentCaptureTemplateResult,
PluginEnvironmentCancelInteractiveSetupParams,
PluginEnvironmentCancelInteractiveSetupResult,
PluginEnvironmentDeleteTemplateParams,
PluginEnvironmentDeleteTemplateResult,
PluginEnvironmentLease,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
} from "@paperclipai/plugin-sdk";
import { unprocessable } from "../errors.js";
import {
collectSecretRefPaths,
parseSecretRefBindingObject,
readConfigValueAtPath,
writeConfigValueAtPath,
} from "./json-schema-secret-refs.js";
import { pluginRegistryService } from "./plugin-registry.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
type AgentEnvironmentRef = { driver: string; config: Record<string, unknown> | null };
type AgentEnvironmentPlugin = {
pluginKey: string;
manifestJson: { environmentDrivers?: Array<{ driverKey: string; kind?: string; requiresAgent?: boolean }> };
};
export function pluginHasAgentEnvironments(plugin: AgentEnvironmentPlugin) {
const drivers = plugin.manifestJson.environmentDrivers?.filter(driver => driver.requiresAgent === true) ?? [];
return drivers.length > 0 && drivers.every(driver => driver.kind !== "sandbox_provider");
}
export function pluginServesAgentEnvironment(plugin: AgentEnvironmentPlugin, environment: AgentEnvironmentRef | null | undefined) {
if (environment?.driver !== "plugin" || environment.config?.pluginKey !== plugin.pluginKey) return false;
return plugin.manifestJson.environmentDrivers?.some(driver => driver.requiresAgent === true && driver.kind !== "sandbox_provider"
&& driver.driverKey === environment.config?.driverKey) ?? false;
}
export async function pluginForAgentEnvironment(db: Db, environment: AgentEnvironmentRef) {
const pluginKey = environment.config?.pluginKey;
if (environment.driver !== "plugin" || typeof pluginKey !== "string") return null;
const plugin = await pluginRegistryService(db).getByKey(pluginKey);
return plugin && pluginServesAgentEnvironment(plugin, environment) ? plugin : null;
}
/** Whether a saved environment change would give active agents a plugin that has not prepared them. */
export async function agentsNeedSetupForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined,
after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) {
const plugin = after ? await pluginForAgentEnvironment(db, after) : null;
if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return false;
const usesEnvironment = or(users.environmentId ? eq(agents.defaultEnvironmentId, users.environmentId) : undefined,
users.inheritsDefault ? isNull(agents.defaultEnvironmentId) : undefined);
if (!usesEnvironment) return false;
const [waiting] = await db.select({ id: agents.id }).from(agents).where(and(usesEnvironment,
inArray(agents.lifecycleState, ["ready", "verifying"]),
sql`not coalesce(${agents.lifecycleRequiredPluginIds}, '[]'::jsonb) @> ${JSON.stringify([plugin.id])}::jsonb`)).limit(1);
return Boolean(waiting);
}
/** Throws when a saved environment would give active agents a plugin that has not prepared them. */
export async function assertAgentsReadyForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined,
after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) {
if (await agentsNeedSetupForEnvironmentChange(db, before, after, users)) {
throw conflict("Agents that use this environment need setup first. Pause those agents, save this change, and then resume them.",
{ code: "environment_requires_agent_setup" });
}
}
export async function pluginEnvironmentRequiresAgent(db: Db, environment: AgentEnvironmentRef) {
return await pluginForAgentEnvironment(db, environment) !== null;
}
/**
* The worker methods a sandbox provider must advertise before the host reuses
* a provider lease across runs. The host resumes a lease through
* `environmentResumeLease`, ends it through `environmentReleaseLease`, and tears
* down a stale lease through `environmentDestroyLease`. The reuse path destroys
* the stale lease when a resume fails and then acquires a fresh lease, so a
* provider that omits any of the three methods can strand the stale lease and
* can never complete the reuse path.
*
* The runtime capability normalizer maps `reusableLeases` to these same methods,
* so the acquisition guard, the effective-capability snapshot, and the published
* provider-capabilities value all read one source and cannot drift.
*/
export const REUSABLE_LEASE_WORKER_METHODS = [
"environmentResumeLease",
"environmentReleaseLease",
"environmentDestroyLease",
] as const;
/**
* The worker methods a sandbox provider must advertise before the customImage
* setup gate in `environment-custom-images.ts` allows an interactive setup
* session. The setup lifecycle starts a session through
* `environmentStartInteractiveSetup`, polls it through
* `environmentGetInteractiveSetup`, and cancels it through
* `environmentCancelInteractiveSetup`. A provider that omits any of the three
* can strand a setup session partway through the lifecycle.
*/
export const INTERACTIVE_SETUP_WORKER_METHODS = [
"environmentStartInteractiveSetup",
"environmentGetInteractiveSetup",
"environmentCancelInteractiveSetup",
] as const;
/** The worker method the customImage template-capture gate in `environment-custom-images.ts` requires. */
export const TEMPLATE_CAPTURE_WORKER_METHODS = [
"environmentCaptureTemplate",
] as const;
/** The worker method the customImage template-delete gate in `environment-custom-images.ts` requires. */
export const TEMPLATE_DELETE_WORKER_METHODS = [
"environmentDeleteTemplate",
] as const;
export interface ReadyPluginWorkerRecovery {
pluginKeys: readonly string[];
startWorker(plugin: { id: string; pluginKey: string }): Promise<boolean>;
timeoutMs?: number;
}
export interface ReadyPluginEnvironmentDriver {
pluginId: string;
pluginKey: string;
driverKey: string;
displayName: string;
description?: string;
configSchema: PluginEnvironmentDriverDeclaration["configSchema"];
supportsReusableLeases?: PluginEnvironmentDriverDeclaration["supportsReusableLeases"];
sandboxCapabilities?: PluginEnvironmentDriverDeclaration["sandboxCapabilities"];
/**
* The running worker for this exact plugin advertises ALL reusable-lease
* lifecycle methods (`REUSABLE_LEASE_WORKER_METHODS`: resume, release, and
* destroy). The published provider-capabilities value grants reusable leases
* only when the declaration allows them AND this flag is true, so presentation
* matches the acquisition guard, which also verifies the same methods live.
*/
reusableLeaseMethodsVerified: boolean;
supportsInteractiveSetup?: PluginEnvironmentDriverDeclaration["supportsInteractiveSetup"];
interactiveSetupConnectionTypes?: PluginEnvironmentDriverDeclaration["interactiveSetupConnectionTypes"];
supportsTemplateCapture?: PluginEnvironmentDriverDeclaration["supportsTemplateCapture"];
templateRefKind?: PluginEnvironmentDriverDeclaration["templateRefKind"];
templateConfigBinding?: PluginEnvironmentDriverDeclaration["templateConfigBinding"];
supportsTemplateDelete?: PluginEnvironmentDriverDeclaration["supportsTemplateDelete"];
supportsLoginPty?: PluginEnvironmentDriverDeclaration["supportsLoginPty"];
}
export function pluginDriverProviderKey(config: Pick<PluginEnvironmentConfig, "pluginKey" | "driverKey">): string {
return `${config.pluginKey}:${config.driverKey}`;
}
const DEFAULT_READY_PLUGIN_WORKER_RECOVERY_TIMEOUT_MS = 2_000;
async function resolveWithTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutValue: T): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return await promise;
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((resolve) => {
timeout = setTimeout(() => resolve(timeoutValue), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export async function resolvePluginEnvironmentDriver(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
}) {
const pluginRegistry = pluginRegistryService(input.db);
const plugin = await pluginRegistry.getByKey(input.config.pluginKey);
if (!plugin || plugin.status !== "ready") {
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(input.config)}" is not ready.`);
}
const driver = plugin.manifestJson.environmentDrivers?.find(
(candidate) => candidate.driverKey === input.config.driverKey,
);
if (!driver) {
throw new Error(`Plugin "${input.config.pluginKey}" does not declare environment driver "${input.config.driverKey}".`);
}
if (!input.workerManager.isRunning(plugin.id)) {
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(input.config)}" has no running worker.`);
}
return { plugin, driver };
}
export async function resolvePluginEnvironmentDriverByKey(input: {
db: Db;
workerManager: PluginWorkerManager;
driverKey: string;
}) {
return await resolvePluginSandboxProviderDriverByKey({
db: input.db,
driverKey: input.driverKey,
workerManager: input.workerManager,
requireRunning: true,
});
}
type CachedProviderSchema = { pluginId: string; revision: string; schema: Record<string, unknown> | null };
const providerSchemas = new WeakMap<Db, Map<string, CachedProviderSchema>>();
const MAX_CACHED_PROVIDER_SCHEMAS = 32;
const MAX_CACHED_PROVIDER_SCHEMA_BYTES = 64 * 1024;
export async function resolvePluginSandboxProviderConfigSchema(input: {
db: Db; driverKey: string;
}): Promise<Record<string, unknown> | null> {
const { db, driverKey } = input;
let cache = providerSchemas.get(db);
const cached = cache?.get(driverKey);
const unchanged = sql`coalesce(plugin_id = ${cached?.pluginId ?? null}::uuid
and revision = ${cached?.revision ?? null}, false)`;
const [row] = await db.execute<{
pluginId: string; revision: string; unchanged: boolean; configSchema: unknown;
}>(sql`with selected_provider as materialized (
select ${plugins.id} as plugin_id, (
select declaration.value->'configSchema'
from jsonb_array_elements(${plugins.manifestJson}->'environmentDrivers')
with ordinality as declaration(value, position)
where declaration.value->'driverKey' = to_jsonb(${driverKey}::text)
and declaration.value->>'kind' = 'sandbox_provider'
order by declaration.position limit 1
) as schema_value
from ${plugins}
where ${plugins.manifestJson} @> ${JSON.stringify({ environmentDrivers: [{ driverKey, kind: "sandbox_provider" }] })}::jsonb
order by ${plugins.installOrder} limit 1
), versioned_provider as materialized (
select plugin_id, schema_value,
encode(sha256(convert_to(coalesce(schema_value::text, 'null'), 'UTF8')), 'hex') as revision
from selected_provider
)
select plugin_id as "pluginId", revision, ${unchanged} as unchanged,
case when ${unchanged} then null else schema_value end as "configSchema"
from versioned_provider`);
if (!row) {
cache?.delete(driverKey);
return null;
}
if (row.unchanged) {
if (!cached || cached.pluginId !== row.pluginId || cached.revision !== row.revision) {
throw new Error("Sandbox provider schema revision did not match the retained definition");
}
return structuredClone(cached.schema);
}
const schema = row.configSchema && typeof row.configSchema === "object" && !Array.isArray(row.configSchema)
? row.configSchema as Record<string, unknown> : null;
cache?.delete(driverKey);
if (Buffer.byteLength(JSON.stringify(schema)) <= MAX_CACHED_PROVIDER_SCHEMA_BYTES) {
if (!cache) { cache = new Map(); providerSchemas.set(db, cache); }
if (cache.size >= MAX_CACHED_PROVIDER_SCHEMAS) cache.delete(cache.keys().next().value!);
cache.set(driverKey, { pluginId: row.pluginId, revision: row.revision, schema: structuredClone(schema) });
}
return schema;
}
export async function resolvePluginSandboxProviderDriverByKey(input: {
db: Db;
driverKey: string;
workerManager?: PluginWorkerManager;
requireRunning?: boolean;
}): Promise<{ plugin: Awaited<ReturnType<ReturnType<typeof pluginRegistryService>["list"]>>[number]; driver: PluginEnvironmentDriverDeclaration } | null> {
const pluginRegistry = pluginRegistryService(input.db);
const plugins = await pluginRegistry.list({ sandboxProviderDriverKey: input.driverKey });
for (const plugin of plugins) {
const driver = plugin.manifestJson.environmentDrivers?.find(
(candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider",
) as PluginEnvironmentDriverDeclaration | undefined;
if (!driver) continue;
if (input.requireRunning) {
if (plugin.status !== "ready") continue;
if (!input.workerManager?.isRunning(plugin.id)) continue;
}
return { plugin, driver };
}
return null;
}
/**
* Resolve the sandbox-provider driver declaration from one exact plugin id.
*
* A driver key is only unique inside a single manifest. Two installed plugins
* can declare the same driver key. A lease pins the plugin that acquired it
* through `metadata.pluginId`. Use this resolver, not the by-key resolver, when
* the caller must read the declaration from that exact plugin. The by-key
* resolver returns the first installed plugin with the key, which can be a
* different, even disabled, plugin.
*
* This resolver fails closed. It returns `null` when the plugin id is unknown,
* or when that plugin no longer declares a `sandbox_provider` driver with the
* given key.
*/
export async function resolvePluginSandboxProviderDriverById(input: {
db: Db;
pluginId: string;
driverKey: string;
}): Promise<{ plugin: Awaited<ReturnType<ReturnType<typeof pluginRegistryService>["getById"]>>; driver: PluginEnvironmentDriverDeclaration } | null> {
const pluginRegistry = pluginRegistryService(input.db);
const plugin = await pluginRegistry.getById(input.pluginId);
if (!plugin) return null;
const driver = plugin.manifestJson.environmentDrivers?.find(
(candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider",
) as PluginEnvironmentDriverDeclaration | undefined;
if (!driver) return null;
return { plugin, driver };
}
export async function listReadyPluginEnvironmentDrivers(input: {
db: Db;
workerManager?: PluginWorkerManager;
recoverMissingWorker?: ReadyPluginWorkerRecovery;
}) {
if (!input.workerManager) return [];
const pluginRegistry = pluginRegistryService(input.db);
const plugins = await pluginRegistry.list();
const recoverablePluginKeys = new Set(input.recoverMissingWorker?.pluginKeys ?? []);
const readyPlugins = plugins.filter((plugin) => plugin.status === "ready");
const recoveryAttempts: Promise<boolean>[] = [];
for (const plugin of readyPlugins) {
const hasSandboxProviderDriver = plugin.manifestJson.environmentDrivers?.some(
(driver) => driver.kind === "sandbox_provider",
) ?? false;
const canRecover =
hasSandboxProviderDriver
&& !input.workerManager.isRunning(plugin.id)
&& recoverablePluginKeys.has(plugin.pluginKey)
&& !input.workerManager.getWorker(plugin.id);
if (!canRecover || !input.recoverMissingWorker) continue;
const timeoutMs =
input.recoverMissingWorker.timeoutMs ?? DEFAULT_READY_PLUGIN_WORKER_RECOVERY_TIMEOUT_MS;
recoveryAttempts.push(resolveWithTimeout(
input.recoverMissingWorker.startWorker({
id: plugin.id,
pluginKey: plugin.pluginKey,
}).catch(() => false),
timeoutMs,
false,
));
}
if (recoveryAttempts.length > 0) {
await Promise.all(recoveryAttempts);
}
const rows: ReadyPluginEnvironmentDriver[] = [];
for (const plugin of readyPlugins) {
if (!input.workerManager.isRunning(plugin.id)) {
continue;
}
// The plugin is running, so read the live worker's verified methods once per
// plugin. A provider advertises reusable leases only when its worker carries
// all reuse lifecycle methods; the declaration alone never grants them.
const workerMethods = new Set(input.workerManager.getWorker(plugin.id)?.supportedMethods ?? []);
const reusableLeaseMethodsVerified = REUSABLE_LEASE_WORKER_METHODS.every(
(method) => workerMethods.has(method),
);
rows.push(
...(plugin.manifestJson.environmentDrivers ?? [])
.filter((driver) => driver.kind === "sandbox_provider")
.map((driver) => ({
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
driverKey: driver.driverKey,
displayName: driver.displayName,
description: driver.description,
configSchema: driver.configSchema,
supportsReusableLeases: driver.supportsReusableLeases,
sandboxCapabilities: driver.sandboxCapabilities,
reusableLeaseMethodsVerified,
supportsInteractiveSetup: driver.supportsInteractiveSetup,
interactiveSetupConnectionTypes: driver.interactiveSetupConnectionTypes,
supportsTemplateCapture: driver.supportsTemplateCapture,
templateRefKind: driver.templateRefKind,
templateConfigBinding: driver.templateConfigBinding,
supportsTemplateDelete: driver.supportsTemplateDelete,
supportsLoginPty: driver.supportsLoginPty,
})),
);
}
return rows;
}
export async function validatePluginSandboxProviderConfig(input: {
db: Db;
workerManager: PluginWorkerManager;
provider: string;
config: Record<string, unknown>;
}): Promise<{
normalizedConfig: Record<string, unknown>;
pluginId: string;
pluginKey: string;
driver: PluginEnvironmentDriverDeclaration;
}> {
const resolved = await resolvePluginSandboxProviderDriverByKey({
db: input.db,
driverKey: input.provider,
workerManager: input.workerManager,
requireRunning: true,
});
if (!resolved) {
throw unprocessable(`Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`);
}
// Secret pickers submit `{ type: "secret_ref", secretId, version }` binding
// objects for `format: "secret-ref"` fields. Plugins only understand string
// config values, so canonicalize bindings to the bare secret id (the
// persisted shape) before the plugin validates.
const configSchema =
resolved.driver.configSchema && typeof resolved.driver.configSchema === "object" && !Array.isArray(resolved.driver.configSchema)
? resolved.driver.configSchema as Record<string, unknown>
: null;
let config = input.config;
for (const path of collectSecretRefPaths(configSchema)) {
const binding = parseSecretRefBindingObject(readConfigValueAtPath(config, path));
if (!binding) continue;
if (binding.version !== "latest") {
throw unprocessable(
`Secret binding at ${path} pins version ${binding.version}; sandbox provider secret references always resolve the latest version.`,
);
}
config = writeConfigValueAtPath(config, path, binding.secretId);
}
const result = await input.workerManager.call(resolved.plugin.id, "environmentValidateConfig", {
driverKey: input.provider,
config,
});
if (!result.ok) {
throw unprocessable(
result.errors?.[0] ?? `Sandbox provider "${input.provider}" rejected its config.`,
{
errors: result.errors ?? [],
warnings: result.warnings ?? [],
},
);
}
return {
normalizedConfig: result.normalizedConfig ?? config,
pluginId: resolved.plugin.id,
pluginKey: resolved.plugin.pluginKey,
driver: resolved.driver,
};
}
export async function validatePluginEnvironmentDriverConfig(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
}): Promise<PluginEnvironmentConfig> {
const { plugin } = await resolvePluginEnvironmentDriver(input);
const result = await input.workerManager.call(plugin.id, "environmentValidateConfig", {
driverKey: input.config.driverKey,
config: input.config.driverConfig,
});
if (!result.ok) {
throw unprocessable(
result.errors?.[0] ?? `Plugin environment driver "${pluginDriverProviderKey(input.config)}" rejected its config.`,
{
errors: result.errors ?? [],
warnings: result.warnings ?? [],
},
);
}
return {
...input.config,
driverConfig: result.normalizedConfig ?? input.config.driverConfig,
};
}
export async function probePluginEnvironmentDriver(input: {
db: Db;
workerManager: PluginWorkerManager;
companyId: string;
environmentId: string;
config: PluginEnvironmentConfig;
}): Promise<EnvironmentProbeResult> {
const { plugin } = await resolvePluginEnvironmentDriver(input);
const result = await input.workerManager.call(plugin.id, "environmentProbe", {
driverKey: input.config.driverKey,
companyId: input.companyId,
environmentId: input.environmentId,
config: input.config.driverConfig,
}, 120_000);
return {
ok: result.ok,
driver: "plugin",
summary: result.summary ?? `Plugin environment driver "${pluginDriverProviderKey(input.config)}" probe ${result.ok ? "passed" : "failed"}.`,
details: {
pluginKey: input.config.pluginKey,
driverKey: input.config.driverKey,
diagnostics: result.diagnostics ?? [],
metadata: result.metadata ?? {},
},
};
}
export async function probePluginSandboxProviderDriver(input: {
db: Db;
workerManager: PluginWorkerManager;
companyId: string;
environmentId: string;
provider: string;
config: Record<string, unknown>;
}): Promise<EnvironmentProbeResult> {
const resolved = await resolvePluginEnvironmentDriverByKey({
db: input.db,
workerManager: input.workerManager,
driverKey: input.provider,
});
if (!resolved) {
return {
ok: false,
driver: "sandbox",
summary: `Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`,
details: {
provider: input.provider,
},
};
}
const { provider: _provider, ...driverConfig } = input.config;
const result = await input.workerManager.call(resolved.plugin.id, "environmentProbe", {
driverKey: input.provider,
companyId: input.companyId,
environmentId: input.environmentId,
config: driverConfig,
}, 120_000);
return {
ok: result.ok,
driver: "sandbox",
summary: result.summary ?? `Sandbox provider "${input.provider}" probe ${result.ok ? "passed" : "failed"}.`,
details: {
provider: input.provider,
pluginKey: resolved.plugin.pluginKey,
diagnostics: result.diagnostics ?? [],
metadata: result.metadata ?? {},
},
};
}
export async function resumePluginEnvironmentLease(input: {
db: Db;
workerManager: PluginWorkerManager;
companyId: string;
environmentId: string;
issueId?: string | null;
config: PluginEnvironmentConfig;
providerLeaseId: string;
leaseMetadata?: Record<string, unknown>;
requestedCwd?: string;
}): Promise<PluginEnvironmentLease> {
const { plugin } = await resolvePluginEnvironmentDriver(input);
return await input.workerManager.call(plugin.id, "environmentResumeLease", {
driverKey: input.config.driverKey,
companyId: input.companyId,
environmentId: input.environmentId,
issueId: input.issueId ?? null,
config: input.config.driverConfig,
providerLeaseId: input.providerLeaseId,
leaseMetadata: input.leaseMetadata,
...(input.requestedCwd !== undefined ? { requestedCwd: input.requestedCwd } : {}),
});
}
export async function destroyPluginEnvironmentLease(input: {
db: Db;
workerManager: PluginWorkerManager;
companyId: string;
environmentId: string;
issueId?: string | null;
config: PluginEnvironmentConfig;
providerLeaseId: string | null;
leaseMetadata?: Record<string, unknown>;
}): Promise<void> {
const { plugin } = await resolvePluginEnvironmentDriver(input);
await input.workerManager.call(plugin.id, "environmentDestroyLease", {
driverKey: input.config.driverKey,
companyId: input.companyId,
environmentId: input.environmentId,
issueId: input.issueId ?? null,
config: input.config.driverConfig,
providerLeaseId: input.providerLeaseId,
leaseMetadata: input.leaseMetadata,
});
}
export async function realizePluginEnvironmentWorkspace(input: {
db: Db;
workerManager: PluginWorkerManager;
pluginId?: string | null;
params: PluginEnvironmentRealizeWorkspaceParams;
config: PluginEnvironmentConfig;
}): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const { plugin } = input.pluginId
? { plugin: { id: input.pluginId } }
: await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentRealizeWorkspace", input.params);
}
export async function executePluginEnvironmentCommand(input: {
db: Db;
workerManager: PluginWorkerManager;
pluginId?: string | null;
params: PluginEnvironmentExecuteParams;
config: PluginEnvironmentConfig;
}): Promise<PluginEnvironmentExecuteResult> {
const { plugin } = input.pluginId
? { plugin: { id: input.pluginId } }
: await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(
plugin.id,
"environmentExecute",
input.params,
resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.params.timeoutMs,
config: input.config.driverConfig,
}),
);
}
export async function startPluginEnvironmentInteractiveSetup(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
params: Omit<PluginEnvironmentStartInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
const { plugin } = await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentStartInteractiveSetup", {
...input.params,
driverKey: input.config.driverKey,
config: input.config.driverConfig,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: undefined,
config: input.config.driverConfig,
}));
}
export async function getPluginEnvironmentInteractiveSetup(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
params: Omit<PluginEnvironmentGetInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
const { plugin } = await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentGetInteractiveSetup", {
...input.params,
driverKey: input.config.driverKey,
config: input.config.driverConfig,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: undefined,
config: input.config.driverConfig,
}));
}
export async function capturePluginEnvironmentTemplate(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
params: Omit<PluginEnvironmentCaptureTemplateParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentCaptureTemplateResult> {
const { plugin } = await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentCaptureTemplate", {
...input.params,
driverKey: input.config.driverKey,
config: input.config.driverConfig,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.params.timeoutMs ?? undefined,
config: input.config.driverConfig,
}));
}
export async function cancelPluginEnvironmentInteractiveSetup(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
params: Omit<PluginEnvironmentCancelInteractiveSetupParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
const { plugin } = await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentCancelInteractiveSetup", {
...input.params,
driverKey: input.config.driverKey,
config: input.config.driverConfig,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: undefined,
config: input.config.driverConfig,
}));
}
export async function deletePluginEnvironmentTemplate(input: {
db: Db;
workerManager: PluginWorkerManager;
config: PluginEnvironmentConfig;
params: Omit<PluginEnvironmentDeleteTemplateParams, "driverKey" | "config">;
}): Promise<PluginEnvironmentDeleteTemplateResult> {
const { plugin } = await resolvePluginEnvironmentDriver({
db: input.db,
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(plugin.id, "environmentDeleteTemplate", {
...input.params,
driverKey: input.config.driverKey,
config: input.config.driverConfig,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: undefined,
config: input.config.driverConfig,
}));
}
const RPC_OVERHEAD_BUFFER_MS = 30_000;
export function resolvePluginExecuteRpcTimeoutMs(input: {
requestedTimeoutMs?: number;
config: Record<string, unknown>;
}): number | undefined {
let baseMs: number | undefined;
if (Number.isFinite(input.requestedTimeoutMs) && (input.requestedTimeoutMs ?? 0) > 0) {
baseMs = Math.trunc(input.requestedTimeoutMs!);
} else {
const configTimeoutMs = typeof input.config.timeoutMs === "number" ? input.config.timeoutMs : null;
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
baseMs = Math.trunc(configTimeoutMs);
}
}
return baseMs != null ? baseMs + RPC_OVERHEAD_BUFFER_MS : undefined;
}