Files
PaperClipAI/server/src/services/plugin-environment-driver.ts
T
Nicky LeachandPaperclip 835966dec8 Prepare agent-owned environments during hiring (#17761)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/1/changes/paperclip/1
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip manages agents and their execution environments.
> - New agent setup saves an inactive agent before model connection
tests.
> - Some plugins need that agent to prepare its execution resource.
> - The wizard tested without the agent ID, while the setup hold
prevented preparation.
> - Environment changes also discarded the selected model account.
> - This change prepares the saved draft during setup and keeps normal
execution held.
> - Users can test the prepared resource before they finish hiring.

## Linked Issues or Issue Description

**What happened?**

An agent-owned plugin environment could not acquire a test lease during
hiring. Changing environments also returned the user to Connect and
cleared the selected account.

**Expected behavior**

Keep the selected account, invalidate its test, and prepare the saved
agent resource in the background. Test that same agent before finishing
setup.

**Steps to reproduce**

1. Start a new Claude or Codex agent and select a saved account.
2. Change to an agent-owned plugin environment.
3. Try to finish setup or reconnect the account.

Related: #17750 defers probes until after hire submission. This change
implements preparation during the wizard instead. The related PR search
and ROADMAP review found no other implementation of this setup
preparation path.

## What Changed

- Add the optional `requiresAgent` driver declaration. Require agent
lifecycle participation when it is set.
- Prepare saved drafts on creation or environment changes. Keep the
setup hold and wait in `verifying` without background verification
scans.
- Bind tests to the saved agent. Return a warning without allocating a
lease when the required agent is absent or still preparing.
- Preserve the account and configuration step when the environment
changes. Save the selection and invalidate the old test.
- Show active preparation and support authorized retries. Keep approval,
company, budget, and normal task admission controls.
- Document the contract and cover lifecycle, lease, manifest, and wizard
behavior.

## Verification

- Server and UI TypeScript checks passed.
- 112 wizard and setup helper tests passed. The shared manifest suite
passed.
- 123 lifecycle, policy, lease, and environment-test API cases passed
after the approval-gate fix. The diagnostic and driver suites and all 80
agent adapter route tests also passed.
- Token gates, module boundaries, map validation, and whitespace checks
passed.
- All 66 lifecycle/policy cases passed after the stopping-draft
permission fix. The new real-DB route case verifies creator success,
other-creator denial, and refusal to complete a hire while stopping.
- Full workspace typecheck and build were attempted. Both stop at the
unchanged Rust Runner step because Cargo is not installed on this host.
- CI on 43be3c93d passed all automated checks, including full test
shards, typecheck, build, Rust checks, browser suites, and canary
checks:
https://github.com/paperclipai/paperclip/actions/runs/40368174530.
- The full local repository test command was started. Two unchanged
workspace tests assume Git's initial branch is master; this host
defaults to main. Both fail in isolation under the host default and pass
with process-scoped init.defaultBranch=master. The broad run was stopped
after those confirmed fixture failures; CI supplies the full clean
check.
- Early new-test failures were fixture collisions and an unscoped sweep
assertion; the fixtures and assertion were corrected. One route-suite
load raced with SDK output regeneration; rebuilding first and running
the suite afterward passed.
- Initial CI exposed a route mock missing the new prerequisite lookup.
The fixture was updated and a real HTTP no-lease case was added; all 54
cases in that route suite pass. Security review identified allocation
before required approval; the new approval gate and its tests address
it.
- CI on 50b5a08af reported 32 of 33 expected calls in the unchanged
cleanup-history scheduling test. Its isolated case passed locally
without cleanup-code changes; the original failure remains recorded and
the final CI run passes on the current head.
- Standard Greptile reviewed 43be3c93d at 5/5. A subsequent deeper
review identified runner selection, stale detail reads, error retention,
and test file size issues; 70343387b addresses all four with resolved
threads. Standard review returned 5/5 on that commit and again on
rebased head fda42c5ab. The rebase preserves master's new managed
environment skill declarations alongside requiresAgent. Full CI on
fda42c5ab passes: 55 successful checks, two intentional skips, no
failures. Run:
https://github.com/paperclipai/paperclip/actions/runs/40405121641.
- After those fixes, 133 UI tests and 61 lifecycle/policy cases pass;
all eight focused UI regressions fail against the previous
implementation and pass with the fix. Token, map, and whitespace gates
pass. The stale-read fixture initially omitted the setup URL because
navigation is mocked; following the URL activates the real detail query
and reproduces the race.
- UI TypeScript passes. This host's server dependency links now point to
a separate scratch checkout lacking requiresAgent, so the ordinary
server check reports that missing property. A no-emit check pinned to
this branch's shared declarations passes; it disables declaration
generation and supplies explicit local type roots. The first temporary
configuration lacked those type roots; the corrected configuration
passes. CI remains the full workspace verification.
- After rebasing onto 8373d7867, all 133 UI, 61 lifecycle/policy, and 44
manifest cases pass again, as do UI TypeScript, token, map, and
whitespace checks. The server no-emit check now hits three mismatched
SDK/Runner declarations from the scratch dependency links; these are
outside this change. Full CI builds the complete dependency set.
- Review cycle: the first Apex review scored 3/5 with four findings. All
were fixed together, the branch was rebased to resolve a schema
conflict, full CI and standard Greptile passed, and a second Apex review
returned 5/5 on fda42c5ab with zero new comments. All six review threads
are resolved, and the PR is conflict-free. Review:
https://github.com/paperclipai/paperclip/pull/17761#issuecomment-6422635718.
- No live provider resource was created and no deployment was performed.

## Risks

Plugins must opt in and ship a compatible declaration. Companies
requiring hire approval keep drafts paused and defer allocation and
verification until approval. Other companies prepare drafts during the
wizard under their existing agent-creation authority; the setup hold
still blocks ordinary work. Preparation uses existing lifecycle
ownership, retry, and cleanup paths. The open wizard polls only while
preparation or stopping is active. No database migration is required.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving model ID and context window size are not
exposed.

## 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 described the issue in-PR using the bug report fields
- [x] I have not referenced internal or instance-local issues or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run the targeted tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks
- [x] All Paperclip gates are green — 55 successful checks and two
intentional skips on fda42c5ab
- [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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-26 15:21:47 -07:00

729 lines
27 KiB
TypeScript

import { plugins, type Db } from "@paperclipai/db";
import { sql } from "drizzle-orm";
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";
export async function pluginEnvironmentRequiresAgent(db: Db, environment: { driver: string; config: Record<string, unknown> | null }) {
if (environment.driver !== "plugin") return false;
const config = environment.config;
if (typeof config?.pluginKey !== "string" || typeof config.driverKey !== "string") return false;
const plugin = await pluginRegistryService(db).getByKey(config.pluginKey);
return plugin?.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.requiresAgent === true) ?? false;
}
/**
* 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>;
}): 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,
});
}
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;
}