mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its instance-level environments from the `PAPERCLIP_MANAGED_CONFIG` document, and #19508 added plugin environment entries to it > - Greptile's Apex review of #19508 arrived after it merged, with three findings > - A credential inside an array nested in an array passed the secret check and the response redaction > - Recovery of an archived environment listened on one worker handle, which a restart replaces, and its attempts were not tracked through idle checks and shutdown > - This pull request closes all three findings > - The benefit is that credentials never pass the check, an environment comes back after any worker restart, and shutdown never cuts off a recovery write ## Linked Issues or Issue Description Refs #19508 (the Apex findings this pull request fixes) **Subsystem affected** Server environments: the managed-config parser, the environment routes' response floor, the managed environment bootstrap, and the plugin worker manager. **Problem or motivation** Greptile's Apex review of #19508 found three problems. (1) `findSecretLikeConfigKey` and `redactSecretLikeConfigKeys` skip array elements that are arrays, so `{"groups":[[{"apiToken":"..."}]]}` passes the boot check and is echoed to restricted readers. (2) Recovery subscribes to `ready` on one worker handle. A restart such as a settings save replaces that handle, so the new worker's `ready` never reaches the listener, and the environment stays archived. (3) A recovery attempt is fire-and-forget. The worker manager's work token ends when the worker replies, but the database write continues, so an idle check or the database shutdown can overlap it. **Proposed solution** Walk arrays at any depth in both functions. Add a manager-wide `onWorkerReady` subscription and use it for recovery. Run every recovery attempt through a controller that tracks it as idle work and that shutdown stops and awaits before the database closes. **Alternatives considered** Re-subscribing on each new handle after a restart. That needs a hook in every restart path. The manager-wide subscription covers all of them in one place. **Roadmap alignment** This only fixes code that #19508 added or touched. It adds no product surface. ## What Changed - `server/src/services/managed-config.ts`: `findSecretLikeConfigKey` walks arrays at any depth (new `findSecretLikeConfigKeyInValue`). This covers sandbox `config` and plugin `driverConfig`. - `server/src/routes/environments.ts`: `redactSecretLikeConfigKeys` walks arrays at any depth (new `redactSecretLikeConfigValue`). - `server/src/services/plugin-worker-manager.ts`: new optional `onWorkerReady(listener)` on `PluginWorkerManager`. It fires on every worker `ready`, including a replacement handle from a restart. A listener error is logged and does not affect the worker. - `server/src/services/managed-environments.ts`: - Sandbox and plugin recovery subscribe through `subscribeWorkerReady`. It prefers `onWorkerReady` and falls back to the handle when the manager has no such method. - New `createManagedEnvironmentRecovery()` controller. Each attempt runs inside `trackIdleWork` for its whole duration. `stop()` removes every `ready` subscription, refuses new attempts, and awaits unfinished ones. - `server/src/index.ts`: creates the controller, passes it to `applyManagedEnvironments`, and awaits `stop()` during shutdown, after the delivery workers stop and before the application teardown and database close. ## Verification - `cd server && npx vitest run src/__tests__/managed-config.test.ts`: a secret-like key in an array nested in an array refuses boot, for plugin `driverConfig` and sandbox `config`. - `npx vitest run src/__tests__/environment-routes.test.ts`: a nested-array credential is removed from the response, and other nested values stay. - `npx vitest run src/services/managed-environments-plugin-entries.test.ts`: - With `onWorkerReady`, recovery never consults the handle. It ignores another plugin's `ready`, re-ensures on this plugin's `ready`, and then unsubscribes. - An attempt counts in `idleWorkSnapshot()` until its ensure settles. `stop()` unsubscribes, waits for the unfinished attempt, and later `ready` events start nothing. - `npx vitest run src/services/managed-environments.test.ts src/__tests__/managed-plugin-environments.test.ts`: pass. - `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 - The redaction now also walks nested arrays in sandbox config responses. Only credential-shaped keys are removed; other values are unchanged. - The boot check now refuses a document that hides a secret-like key in a nested array. A valid document has no such key. - `onWorkerReady` is optional, so other `PluginWorkerManager` implementations and test fakes keep working. - Shutdown now waits for an unfinished recovery attempt. An attempt is one worker call plus one short transaction. > 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>
748 lines
33 KiB
TypeScript
748 lines
33 KiB
TypeScript
/**
|
|
* Managed-environment bootstrap (harness → app contract).
|
|
*
|
|
* Managed-cloud instances may declare sandbox environments in the
|
|
* `environments` section of `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed in
|
|
* `managed-config.ts`). On boot, each declared environment is idempotently
|
|
* ensured as the instance-level Paperclip-managed sandbox row via the
|
|
* provider-agnostic `ensureManagedSandboxEnvironment` — the control plane
|
|
* provisions, tenants use, for any bundled sandbox provider plugin.
|
|
*
|
|
* The failure posture mirrors bundled plugin provisioning
|
|
* (`bundled-plugins.ts`), deliberately split:
|
|
*
|
|
* 1. **Validation fails closed at parse time** (`managed-config.ts`): a
|
|
* malformed section refuses startup with a precise error.
|
|
* 2. **The DB ensure step is fail-safe per entry**: an ensure failure is
|
|
* logged and boot continues degraded (environment unavailable) rather
|
|
* than crash-looping a fleet.
|
|
*
|
|
* Ensuring is additionally synchronized with provider availability: the
|
|
* caller's `pluginsReady` promise (the bundled-plugin install/load pass) is
|
|
* awaited first, and an entry whose provider plugin is not installed, `ready`,
|
|
* AND running a live worker (`workerManager.isRunning`; a `ready` record whose
|
|
* activation failed has no worker and cannot serve leases) afterwards is
|
|
* skipped (counted failed) instead of being written as an active row —
|
|
* otherwise the heartbeat would resume queued runs against an environment
|
|
* whose lease acquisition cannot succeed yet. When such an entry was
|
|
* provisioned by an earlier boot, its still-active row is archived
|
|
* (`archiveManagedSandboxEnvironment`) for the same reason. Re-activation
|
|
* happens on the next healthy boot's ensure, or earlier: when the plugin
|
|
* record is `ready` and only the worker is down (a crash in restart-backoff),
|
|
* a one-shot `ready` listener on the worker handle re-runs the ensure as soon
|
|
* as the worker manager respawns the worker, so a transient crash does not
|
|
* leave the environment archived until someone restarts the server.
|
|
*
|
|
* Removing an entry from the document stops future refreshes but never
|
|
* deletes or archives the row — there is intentionally no unprovision path
|
|
* here, matching `plugins.autoInstall` semantics (leases may still reference
|
|
* the row; withdrawal is an explicit operator action). Archiving above is
|
|
* scoped to a declared-but-unavailable provider, not to document removal.
|
|
*
|
|
* Provider credentials are never part of the declared config: every bundled
|
|
* sandbox provider falls back to its documented process environment variable
|
|
* (e.g. `DAYTONA_API_KEY`) when `config` omits the key, so the deployment
|
|
* delivers secrets as env vars and the managed document stays secret-free.
|
|
*
|
|
* `driver: "plugin"` entries take the same readiness path after the sandbox
|
|
* entry: await `pluginsReady`, require the plugin `ready`, declaring the
|
|
* entry's driver, and running a worker; else archive the entry's row and
|
|
* re-ensure it on the worker's `ready`. Each entry owns one row (see
|
|
* `managed-plugin-environments.ts`). Plugin entries never touch the instance
|
|
* default.
|
|
*/
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
import { HttpError } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { environmentService } from "./environments.js";
|
|
import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import type { ManagedInstanceConfig } from "./managed-config.js";
|
|
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
|
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
|
import { resolvePluginSandboxProviderDriverByKey } from "./plugin-environment-driver.js";
|
|
import { pluginRegistryService } from "./plugin-registry.js";
|
|
import { trackIdleWork } from "./task-admission.js";
|
|
import { resolveBundledCatalogRoot, resolveBundledPluginInstalls } from "./bundled-plugins.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import type { ManagedPluginEnvironmentReconcileAction } from "./managed-plugin-environments.js";
|
|
|
|
/**
|
|
* Lifecycle of the recovery work `applyManagedEnvironments` leaves behind
|
|
* after boot (re-ensuring an archived environment when its plugin worker
|
|
* becomes ready).
|
|
*/
|
|
export interface ManagedEnvironmentRecovery {
|
|
/** Run one attempt as idle-tracked work, unless stopped. */
|
|
run(work: () => Promise<void>): void;
|
|
/** Register an unsubscribe function for `stop()`. Calls it at once when stopped. */
|
|
onStop(unsubscribe: () => void): void;
|
|
/** Unsubscribe every listener, refuse new attempts, and await unfinished ones. */
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
export function createManagedEnvironmentRecovery(): ManagedEnvironmentRecovery {
|
|
const inFlight = new Set<Promise<void>>();
|
|
const unsubscribers = new Set<() => void>();
|
|
let stopped = false;
|
|
return {
|
|
run(work) {
|
|
if (stopped) return;
|
|
const attempt: Promise<void> = trackIdleWork(work())
|
|
.catch(() => undefined)
|
|
.finally(() => { inFlight.delete(attempt); });
|
|
inFlight.add(attempt);
|
|
},
|
|
onStop(unsubscribe) {
|
|
if (stopped) unsubscribe();
|
|
else unsubscribers.add(unsubscribe);
|
|
},
|
|
async stop() {
|
|
stopped = true;
|
|
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
unsubscribers.clear();
|
|
while (inFlight.size > 0) await Promise.allSettled([...inFlight]);
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface ApplyManagedEnvironmentsOptions {
|
|
env?: Record<string, string | undefined>;
|
|
/**
|
|
* Resolves when the bundled-plugin startup pass (install + load; the
|
|
* `ensureBundledPlugins` chain in `createApp`) has finished. Awaited before
|
|
* any environment is ensured so an active row never precedes its provider
|
|
* driver. The promise never rejects (the chain catches internally).
|
|
*/
|
|
pluginsReady?: Promise<unknown>;
|
|
/**
|
|
* The app's plugin worker manager, consulted per entry so an environment is
|
|
* only ensured while its provider plugin has a live worker (a `ready`
|
|
* registry record whose activation failed cannot serve leases). Fails
|
|
* closed: without a worker manager every entry is treated as unavailable.
|
|
*
|
|
* `getWorker` is `PluginWorkerManager.getWorker` narrowed to the `ready`
|
|
* subscription used for post-archive recovery: a worker in crash-restart
|
|
* backoff re-emits `ready` when the manager respawns it, and the listener
|
|
* re-ensures the archived environment (see `applyManagedEnvironments`).
|
|
*
|
|
* Plugin entries also need `call`, to run the plugin's own config
|
|
* validation; without it every plugin entry is treated as unavailable.
|
|
*/
|
|
workerManager?: Pick<PluginWorkerManager, "isRunning"> & Partial<Pick<PluginWorkerManager, "call" | "onWorkerReady">> & {
|
|
getWorker(pluginId: string):
|
|
| {
|
|
on(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
off(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
}
|
|
| undefined;
|
|
};
|
|
/** Test seam: overrides the environment service built from `db`. */
|
|
environments?: Pick<
|
|
ReturnType<typeof environmentService>,
|
|
| "ensureManagedSandboxEnvironment"
|
|
| "archiveManagedSandboxEnvironment"
|
|
| "getById"
|
|
| "update"
|
|
| "findManagedSandboxEnvironment"
|
|
| "ensureManagedPluginEnvironment"
|
|
| "archiveManagedPluginEnvironment"
|
|
>;
|
|
/** Test seam: overrides the instance-settings service built from `db`. */
|
|
instanceSettings?: Pick<ReturnType<typeof instanceSettingsService>, "get" | "update">;
|
|
/** Test seam: overrides the sandbox-provider plugin driver lookup. */
|
|
resolveSandboxProviderDriver?: (input: {
|
|
db: Db;
|
|
driverKey: string;
|
|
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string } } | null>;
|
|
/**
|
|
* Test seam: overrides the lookup of a plugin entry's plugin. Returns null
|
|
* when the plugin is not installed; `declaresDriver` is false when its
|
|
* manifest does not declare the entry's environment driver.
|
|
*/
|
|
resolveEnvironmentPlugin?: (input: {
|
|
db: Db;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string }; declaresDriver: boolean } | null>;
|
|
/**
|
|
* Manifest ids (`pluginKey`) that the `plugins.autoInstall` catalog keys
|
|
* resolve to: `app.locals.managedBundledPluginKeys`, from
|
|
* `resolveBundledPluginInstalls` over the bundled and distribution
|
|
* catalogs. A plugin entry whose `pluginKey` is not in this list refuses
|
|
* startup. When omitted, the list is resolved here the same way.
|
|
*/
|
|
autoInstalledPluginKeys?: readonly string[];
|
|
/**
|
|
* Owns the post-boot recovery attempts and their `ready` subscriptions:
|
|
* each attempt counts as idle-tracked work, and `stop()` (called on
|
|
* shutdown before the database closes) unsubscribes and awaits unfinished
|
|
* attempts. Defaults to a controller nobody stops (tests).
|
|
*/
|
|
recovery?: ManagedEnvironmentRecovery;
|
|
}
|
|
|
|
export interface ManagedPluginEnvironmentReconciliationOutcome {
|
|
environmentId: string;
|
|
name: string;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
action: ManagedPluginEnvironmentReconcileAction;
|
|
}
|
|
|
|
export interface ManagedEnvironmentReconciliationOutcome {
|
|
environmentId: string;
|
|
name: string;
|
|
provider: string;
|
|
action: ManagedSandboxEnvironmentReconcileAction;
|
|
stockStatus: ManagedResourceStockStatus;
|
|
updateAvailable: boolean;
|
|
}
|
|
|
|
export interface ApplyManagedEnvironmentsResult {
|
|
ensured: number;
|
|
failed: number;
|
|
added: number;
|
|
updated: number;
|
|
unchanged: number;
|
|
skipped: number;
|
|
/** Managed reconciliation never removes rows in preserve-only mode. */
|
|
removed: number;
|
|
/** Preserve-only reconciliation never creates replacement backups. */
|
|
backedUp: number;
|
|
outcomes: ManagedEnvironmentReconciliationOutcome[];
|
|
pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[];
|
|
}
|
|
|
|
/**
|
|
* The plugin refused the entry's config (`HttpError` 422 from
|
|
* `normalizeEnvironmentConfig` or `validatePluginEnvironmentDriverConfig`).
|
|
* Retrying cannot help until the config changes. Every other failure, such
|
|
* as a worker that crashed mid-call, is treated as transient.
|
|
*/
|
|
function isPermanentConfigRejection(err: unknown): boolean {
|
|
return err instanceof HttpError && err.status === 422;
|
|
}
|
|
|
|
async function resolveEnvironmentPluginByKey(input: { db: Db; pluginKey: string; driverKey: string }) {
|
|
const plugin = await pluginRegistryService(input.db).getByKey(input.pluginKey);
|
|
if (!plugin) return null;
|
|
return {
|
|
plugin: { id: plugin.id, pluginKey: plugin.pluginKey, status: plugin.status },
|
|
declaresDriver: plugin.manifestJson.environmentDrivers?.some(
|
|
(driver) => driver.driverKey === input.driverKey && driver.kind !== "sandbox_provider",
|
|
) ?? false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure every environment declared in the managed-config document. Returns
|
|
* null when there is nothing to do (self-hosted, or no `environments`
|
|
* section); otherwise the ensured/failed counts. Idempotent; safe to call on
|
|
* every boot.
|
|
*/
|
|
export async function applyManagedEnvironments(
|
|
db: Db,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
opts: ApplyManagedEnvironmentsOptions = {},
|
|
): Promise<ApplyManagedEnvironmentsResult | null> {
|
|
if (!managedConfig) return null;
|
|
|
|
const settings = opts.instanceSettings ?? instanceSettingsService(db);
|
|
const environments = opts.environments ?? environmentService(db);
|
|
const managedSandboxOnlyDeclared = managedConfig.features.enableManagedSandboxOnly === true;
|
|
|
|
// Mode-off default cleanup runs BEFORE any ensure, and regardless of
|
|
// whether the document still declares environments or the provider is
|
|
// available: a reconciliation-stamped default must not outlive the mode
|
|
// through the paths that skip per-entry reconciliation entirely — the
|
|
// declaration was removed, the provider is down, or the row was
|
|
// archived. Only a default that points at the managed row AND carries
|
|
// the `managedDefaultStamped` marker reverts; tenant-chosen defaults
|
|
// (no marker) are untouched.
|
|
if (!managedSandboxOnlyDeclared) {
|
|
try {
|
|
const managedRow = await environments.findManagedSandboxEnvironment(undefined, {
|
|
includeArchived: true,
|
|
});
|
|
if (
|
|
managedRow &&
|
|
managedRow.metadata?.managedDefaultStamped === true &&
|
|
((await settings.get()).defaultEnvironmentId ?? null) === managedRow.id
|
|
) {
|
|
await settings.update({ defaultEnvironmentId: null });
|
|
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedRow.metadata ?? {};
|
|
await environments.update(managedRow.id, { metadata: remainingMetadata });
|
|
logger.info(
|
|
{ environmentId: managedRow.id },
|
|
"instance default environment reverted from the managed sandbox environment (managed-sandbox-only is not declared)",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err },
|
|
"failed to revert the stamped managed sandbox instance default (degraded: stale default until the next boot)",
|
|
);
|
|
}
|
|
}
|
|
|
|
const pluginSpecs = managedConfig.pluginEnvironments;
|
|
if (managedConfig.environments.length === 0 && pluginSpecs.length === 0) return null;
|
|
|
|
// The forced-execution-mode bootstrap (`PAPERCLIP_EXECUTION_MODE=kubernetes`)
|
|
// and this one both own the single Paperclip-managed sandbox row
|
|
// (`environments_managed_sandbox_idx`). Configuring both is contradictory;
|
|
// refuse startup rather than let bootstrap ordering pick a winner. Plugin
|
|
// entries do not touch that slot.
|
|
const env = opts.env ?? process.env;
|
|
if (managedConfig.environments.length > 0 && parseExecutionPolicyBootstrapEnv(env)) {
|
|
throw new Error(
|
|
`PAPERCLIP_EXECUTION_MODE and the PAPERCLIP_MANAGED_CONFIG "environments" section are mutually exclusive: both manage the single instance sandbox environment`,
|
|
);
|
|
}
|
|
|
|
// Coherence, fail closed like the parser: a plugin entry names its plugin by
|
|
// manifest id, and only `plugins.autoInstall` provisions plugins on a
|
|
// managed instance, so some auto-installed catalog entry must install it.
|
|
if (pluginSpecs.length > 0) {
|
|
const autoInstalledPluginKeys = opts.autoInstalledPluginKeys ?? resolveBundledPluginInstalls(
|
|
managedConfig.plugins.autoInstall,
|
|
{ catalogRoot: resolveBundledCatalogRoot(env), env, enforceCatalogRoot: true },
|
|
).map((install) => install.pluginKey);
|
|
for (const spec of pluginSpecs) {
|
|
if (!autoInstalledPluginKeys.includes(spec.pluginKey)) {
|
|
throw new Error(
|
|
`PAPERCLIP_MANAGED_CONFIG "environments" entry "${spec.name}" uses plugin "${spec.pluginKey}", which no "plugins.autoInstall" catalog entry installs; a managed plugin environment requires its plugin to be provisioned`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The heartbeat resumes queued runs right after this bootstrap step, and
|
|
// lease acquisition fails hard on a provider whose plugin is missing or not
|
|
// ready. Wait for the bundled-plugin startup pass to finish, then refuse to
|
|
// ensure (and in particular to re-activate) a row whose provider driver did
|
|
// not come up — a degraded boot without the row beats an active environment
|
|
// that fails every lease until the plugin recovers.
|
|
await opts.pluginsReady;
|
|
|
|
const resolveDriver = opts.resolveSandboxProviderDriver ?? resolvePluginSandboxProviderDriverByKey;
|
|
|
|
/**
|
|
* Point the instance default at the managed sandbox row when no
|
|
* deliberate choice stands in the way: an unset default, a default on
|
|
* the (hidden-under-this-mode) local row, or a dangling reference all
|
|
* move to the managed environment, so pickers and run selection agree
|
|
* that "the default" is the platform sandbox. A tenant-chosen custom
|
|
* environment (ssh, their own sandbox) is never overridden.
|
|
*
|
|
* Gated symmetrically on the document declaring
|
|
* `enableManagedSandboxOnly`:
|
|
*
|
|
* - Declared: stamp the managed row as the default, so pickers and run
|
|
* selection agree the platform sandbox is "the default". The stamp is
|
|
* recorded as `managedDefaultStamped` in the row's platform-owned
|
|
* metadata (which boot reconciliation preserves and the client write
|
|
* floor protects), so a stamped default is distinguishable from one
|
|
* the tenant chose deliberately.
|
|
* - Not declared: local execution is a legitimate default again, so a
|
|
* default THIS reconciliation stamped earlier (points at the managed
|
|
* row AND carries the stamp marker) reverts to unset — otherwise
|
|
* turning the mode off would keep routing default-following runs
|
|
* into the sandbox off a stale stamp. A default the tenant selected
|
|
* themselves — the managed row without the marker, or any custom
|
|
* environment — is untouched in both directions.
|
|
*
|
|
* Idempotent and best-effort — a failure degrades to the run-time
|
|
* policy, which refuses local under managed-sandbox-only regardless.
|
|
*/
|
|
const ensureManagedInstanceDefault = async (managedEnvironment: {
|
|
id: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}): Promise<void> => {
|
|
if (!managedSandboxOnlyDeclared) return;
|
|
try {
|
|
const current = (await settings.get()).defaultEnvironmentId ?? null;
|
|
if (current === managedEnvironment.id) return;
|
|
if (current !== null) {
|
|
const currentEnvironment = await environments.getById(current);
|
|
if (currentEnvironment && currentEnvironment.driver !== "local") return;
|
|
}
|
|
// Marker first: a crash between the two writes must never leave a
|
|
// stamped default that the revert path cannot attribute.
|
|
await environments.update(managedEnvironment.id, {
|
|
metadata: { ...(managedEnvironment.metadata ?? {}), managedDefaultStamped: true },
|
|
});
|
|
await settings.update({ defaultEnvironmentId: managedEnvironment.id });
|
|
logger.info(
|
|
{ environmentId: managedEnvironment.id, previousDefaultEnvironmentId: current },
|
|
"instance default environment set to the managed sandbox environment",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, environmentId: managedEnvironment.id },
|
|
"failed to reconcile the instance default environment with the managed sandbox environment",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Recovery path for a `ready` plugin whose worker was down at check time:
|
|
// that shape is usually a crash in restart-backoff, and the manager
|
|
// re-emits `ready` when it respawns or replaces the worker. A one-shot
|
|
// listener re-runs the idempotent ensure so the recovered provider becomes
|
|
// selectable again without waiting for the next boot (this pass has already
|
|
// archived the row). The post-subscribe `isRunning` re-check closes the race
|
|
// where the worker recovered between the gate check and the subscription.
|
|
// Each attempt runs through `recovery`, so idle checks count it and shutdown
|
|
// awaits it.
|
|
const recovery = opts.recovery ?? createManagedEnvironmentRecovery();
|
|
|
|
/**
|
|
* Call `listener` on each `ready` of the plugin's worker. Prefers the
|
|
* manager-wide subscription, which survives a restart replacing the worker
|
|
* handle (for example a settings save that restarts the worker). Returns
|
|
* the unsubscribe function, or null when there is no way to hear a `ready`
|
|
* (no manager-wide subscription and no handle: activation never started a
|
|
* worker, so no respawn is coming).
|
|
*/
|
|
const subscribeWorkerReady = (pluginId: string, listener: () => void): (() => void) | null => {
|
|
const manager = opts.workerManager;
|
|
if (manager?.onWorkerReady) {
|
|
return manager.onWorkerReady((readyPluginId) => {
|
|
if (readyPluginId === pluginId) listener();
|
|
});
|
|
}
|
|
const handle = manager?.getWorker(pluginId);
|
|
if (!handle) return null;
|
|
handle.on("ready", listener);
|
|
return () => handle.off("ready", listener);
|
|
};
|
|
|
|
const scheduleRecoveryReactivation = (pluginId: string, reactivateEntry: () => Promise<void>): void => {
|
|
let reactivated = false;
|
|
let unsubscribe: (() => void) | null = null;
|
|
const reactivate = (): void => {
|
|
if (reactivated) return;
|
|
reactivated = true;
|
|
unsubscribe?.();
|
|
recovery.run(reactivateEntry);
|
|
};
|
|
unsubscribe = subscribeWorkerReady(pluginId, reactivate);
|
|
if (!unsubscribe) return;
|
|
recovery.onStop(unsubscribe);
|
|
if (opts.workerManager?.isRunning(pluginId)) reactivate();
|
|
};
|
|
|
|
const reactivateSandboxEntry = async (spec: ManagedInstanceConfig["environments"][number]): Promise<void> => {
|
|
try {
|
|
const result = await environments.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
platformFullyManaged: true,
|
|
});
|
|
logger.info(
|
|
{
|
|
environmentId: result.environment.id,
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
action: result.action,
|
|
stockStatus: result.stockStatus,
|
|
updateAvailable: result.updateAvailable,
|
|
},
|
|
"managed sandbox environment reactivated after provider worker recovery",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to reactivate managed sandbox environment after provider worker recovery (degraded: environment unavailable until next boot)",
|
|
);
|
|
}
|
|
};
|
|
|
|
let ensured = 0;
|
|
let failed = 0;
|
|
let added = 0;
|
|
let updated = 0;
|
|
let unchanged = 0;
|
|
let skipped = 0;
|
|
const outcomes: ManagedEnvironmentReconciliationOutcome[] = [];
|
|
for (const spec of managedConfig.environments) {
|
|
try {
|
|
const resolved = await resolveDriver({ db, driverKey: spec.provider });
|
|
// `ready` in the registry is necessary but not sufficient: activation
|
|
// can fail after install leaves the record `ready`, in which case no
|
|
// worker is running and the driver cannot serve leases.
|
|
const workerRunning =
|
|
resolved != null && (opts.workerManager?.isRunning(resolved.plugin.id) ?? false);
|
|
if (!resolved || resolved.plugin.status !== "ready" || !workerRunning) {
|
|
failed += 1;
|
|
// A row provisioned by an earlier boot must not stay active either —
|
|
// archive it so run scheduling stops selecting it. Best-effort: an
|
|
// archive failure is logged, and the entry is already counted failed.
|
|
const archived = await environments
|
|
.archiveManagedSandboxEnvironment({ provider: spec.provider })
|
|
.catch((archiveErr: unknown) => {
|
|
logger.error(
|
|
{ err: archiveErr, name: spec.name, provider: spec.provider },
|
|
"failed to archive the managed sandbox environment of an unavailable provider",
|
|
);
|
|
return null;
|
|
});
|
|
logger.error(
|
|
{
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
pluginKey: resolved?.plugin.pluginKey ?? null,
|
|
pluginStatus: resolved?.plugin.status ?? null,
|
|
workerRunning,
|
|
archivedEnvironmentId: archived?.id ?? null,
|
|
},
|
|
"managed sandbox environment provider plugin is not installed, ready, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
|
);
|
|
// Only a `ready` record can recover without another boot: the worker
|
|
// manager restarts crashed workers, but nothing (re)installs a missing
|
|
// or non-ready plugin at runtime.
|
|
if (resolved && resolved.plugin.status === "ready") {
|
|
scheduleRecoveryReactivation(resolved.plugin.id, () => reactivateSandboxEntry(spec));
|
|
}
|
|
continue;
|
|
}
|
|
const reconciliation = await environments.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
platformFullyManaged: true,
|
|
});
|
|
if (reconciliation.action === "skipped") skipped += 1;
|
|
else {
|
|
ensured += 1;
|
|
if (reconciliation.action === "added") added += 1;
|
|
else if (reconciliation.action === "updated") updated += 1;
|
|
else unchanged += 1;
|
|
}
|
|
outcomes.push({
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
});
|
|
await ensureManagedInstanceDefault(reconciliation.environment);
|
|
const logContext = {
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
};
|
|
if (reconciliation.action === "skipped") {
|
|
logger.warn(
|
|
logContext,
|
|
"managed sandbox environment has operator modifications; preserving row and skipping stock update",
|
|
);
|
|
} else {
|
|
logger.info(logContext, "managed sandbox environment reconciled");
|
|
}
|
|
} catch (err) {
|
|
failed += 1;
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to ensure managed sandbox environment; continuing boot (degraded: environment unavailable)",
|
|
);
|
|
}
|
|
}
|
|
const resolvePlugin = opts.resolveEnvironmentPlugin ?? resolveEnvironmentPluginByKey;
|
|
const pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[] = [];
|
|
|
|
const ensurePluginEntry = (
|
|
spec: ManagedInstanceConfig["pluginEnvironments"][number],
|
|
workerManager: Pick<PluginWorkerManager, "isRunning" | "call">,
|
|
) =>
|
|
environments.ensureManagedPluginEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
pluginKey: spec.pluginKey,
|
|
driverKey: spec.driverKey,
|
|
driverConfig: { ...spec.driverConfig },
|
|
workerManager,
|
|
});
|
|
|
|
type PluginSpec = ManagedInstanceConfig["pluginEnvironments"][number];
|
|
type PluginWorkerAccess = Pick<PluginWorkerManager, "isRunning" | "call">;
|
|
|
|
const archivePluginEntry = (spec: PluginSpec) =>
|
|
environments
|
|
.archiveManagedPluginEnvironment({ pluginKey: spec.pluginKey, driverKey: spec.driverKey })
|
|
.catch((archiveErr: unknown) => {
|
|
logger.error(
|
|
{ err: archiveErr, name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey },
|
|
"failed to archive the managed plugin environment of an unavailable plugin",
|
|
);
|
|
return null;
|
|
});
|
|
|
|
/**
|
|
* Re-ensure a plugin entry each time its worker reports `ready`, until one
|
|
* ensure succeeds or the plugin permanently rejects the entry's config. A
|
|
* worker that crashes during an attempt (for example inside
|
|
* `environmentValidateConfig`) leaves the listener armed, so its next
|
|
* respawn retries. A `ready` that arrives during an attempt queues one more
|
|
* attempt. The subscription survives a restart that replaces the worker
|
|
* handle (see `subscribeWorkerReady`).
|
|
*/
|
|
const schedulePluginRecovery = (spec: PluginSpec, pluginId: string, workerAccess: PluginWorkerAccess): void => {
|
|
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
|
let settled = false;
|
|
let inFlight = false;
|
|
let queued = false;
|
|
let unsubscribe: (() => void) | null = null;
|
|
const settle = (): void => {
|
|
settled = true;
|
|
unsubscribe?.();
|
|
};
|
|
const attempt = (): void => {
|
|
if (settled) return;
|
|
if (inFlight) {
|
|
queued = true;
|
|
return;
|
|
}
|
|
inFlight = true;
|
|
recovery.run(async () => {
|
|
try {
|
|
const result = await ensurePluginEntry(spec, workerAccess);
|
|
settle();
|
|
logger.info(
|
|
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
|
"managed plugin environment reactivated after plugin worker recovery",
|
|
);
|
|
} catch (err) {
|
|
if (isPermanentConfigRejection(err)) {
|
|
settle();
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"the plugin rejected the managed plugin environment's config; not retrying (degraded: environment unavailable until the config changes)",
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
{ err, ...logBase },
|
|
"failed to reactivate managed plugin environment; retrying when the plugin worker is ready again",
|
|
);
|
|
}
|
|
} finally {
|
|
inFlight = false;
|
|
if (queued && !settled) {
|
|
queued = false;
|
|
attempt();
|
|
}
|
|
}
|
|
});
|
|
};
|
|
unsubscribe = subscribeWorkerReady(pluginId, attempt);
|
|
if (!unsubscribe) return;
|
|
recovery.onStop(unsubscribe);
|
|
if (opts.workerManager?.isRunning(pluginId)) attempt();
|
|
};
|
|
|
|
for (const spec of pluginSpecs) {
|
|
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
|
let recoverable: { pluginId: string; workerAccess: PluginWorkerAccess } | null = null;
|
|
try {
|
|
const resolved = await resolvePlugin({ db, pluginKey: spec.pluginKey, driverKey: spec.driverKey });
|
|
const manager = opts.workerManager;
|
|
const call = manager?.call;
|
|
// Only a `ready` record that declares the driver can recover without
|
|
// another boot: the worker manager restarts crashed workers, but nothing
|
|
// (re)installs a missing or non-ready plugin at runtime.
|
|
if (resolved && resolved.plugin.status === "ready" && resolved.declaresDriver && manager && call) {
|
|
recoverable = {
|
|
pluginId: resolved.plugin.id,
|
|
workerAccess: { isRunning: manager.isRunning.bind(manager), call: call.bind(manager) },
|
|
};
|
|
}
|
|
const workerRunning = resolved != null && (manager?.isRunning(resolved.plugin.id) ?? false);
|
|
if (!recoverable || !workerRunning) {
|
|
failed += 1;
|
|
const archived = await archivePluginEntry(spec);
|
|
logger.error(
|
|
{
|
|
...logBase,
|
|
pluginStatus: resolved?.plugin.status ?? null,
|
|
declaresDriver: resolved?.declaresDriver ?? null,
|
|
workerRunning,
|
|
archivedEnvironmentId: archived?.id ?? null,
|
|
},
|
|
"managed plugin environment's plugin is not installed, ready, declaring its driver, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
|
);
|
|
if (recoverable) schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
|
continue;
|
|
}
|
|
const result = await ensurePluginEntry(spec, recoverable.workerAccess);
|
|
ensured += 1;
|
|
if (result.action === "added") added += 1;
|
|
else if (result.action === "unchanged") unchanged += 1;
|
|
else updated += 1;
|
|
pluginOutcomes.push({
|
|
environmentId: result.environment.id,
|
|
name: result.environment.name,
|
|
pluginKey: spec.pluginKey,
|
|
driverKey: spec.driverKey,
|
|
action: result.action,
|
|
});
|
|
logger.info(
|
|
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
|
"managed plugin environment reconciled",
|
|
);
|
|
} catch (err) {
|
|
failed += 1;
|
|
if (recoverable && !isPermanentConfigRejection(err)) {
|
|
if (!recoverable.workerAccess.isRunning(recoverable.pluginId)) {
|
|
// The worker died after the readiness check (for example inside
|
|
// `environmentValidateConfig`): treat it like a plugin that is
|
|
// down, so the row is not left active, and retry on its `ready`.
|
|
const archived = await archivePluginEntry(spec);
|
|
logger.error(
|
|
{ err, ...logBase, archivedEnvironmentId: archived?.id ?? null },
|
|
"managed plugin environment's worker stopped during ensure; archived any previously provisioned row and retrying when the plugin worker is ready again (degraded: environment unavailable)",
|
|
);
|
|
} else {
|
|
// The worker is still running (for example the call timed out).
|
|
// Keep any previously provisioned row as it is, so agents keep a
|
|
// working environment; retry once now and on the worker's next
|
|
// `ready`, and otherwise at the next boot.
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"failed to ensure managed plugin environment while its worker is running; keeping any previously provisioned row and retrying (degraded: declaration not applied)",
|
|
);
|
|
}
|
|
schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
|
} else {
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"failed to ensure managed plugin environment; continuing boot (degraded: environment unavailable)",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ensured,
|
|
failed,
|
|
added,
|
|
updated,
|
|
unchanged,
|
|
skipped,
|
|
removed: 0,
|
|
backedUp: 0,
|
|
outcomes,
|
|
pluginOutcomes,
|
|
};
|
|
}
|