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 > - That document cannot declare a plugin environment yet, so a managed instance cannot get a plugin-provided environment without manual setup > - The full change is split into three pull requests. This first one adds the plugin environment rows and their write floor, and nothing calls them yet > - The second pull request lets the managed config declare plugin entries and provisions them at boot. The third sets the instance default once and advertises the new managed-config capability > - The benefit is a smaller review for each step, and no instance changes behavior until the last step lands ## Linked Issues or Issue Description **Subsystem affected** Server environments (managed-config environment provisioning, environment routes) and the board UI's adapter Test target. **Problem or motivation** A managed instance cannot get a plugin environment from its managed config. Company settings cannot create one either. So each managed instance that needs one requires manual setup. **Proposed solution** Over three pull requests, let an `environments` entry in the managed config declare `driver: "plugin"`. Boot then provisions one locked row for each plugin entry, and makes one of them the instance default once on new instances. This pull request adds only the row layer: create or adopt a marked row, archive it, find it, and keep tenants from editing it. **Alternatives considered** Manual setup on each instance, and a new control-plane endpoint. Both need a step outside the deploy. The managed config already delivers the sandbox entry. **Roadmap alignment** This extends the existing managed-config `environments` section, which already provisions the managed sandbox. It adds no new product surface. ## What Changed - New `server/src/services/managed-plugin-environments.ts`, used through new `environmentService` methods (`ensureManagedPluginEnvironment`, `archiveManagedPluginEnvironment`, `findManagedPluginEnvironment`): - The ensure validates the driver config through `normalizeEnvironmentConfig` and `validatePluginEnvironmentDriverConfig`, the same path an operator-created plugin environment takes. - It marks the row `managedByPaperclip: true` and `managedPluginEnvironment: "<pluginKey>:<driverKey>"`. - It adopts an existing unmarked row for the same plugin and driver instead of adding a second one, so a hand-made row keeps its agents. - A row that already matches gets no write. Changes write activity log entries. - Archive sets an active marked row to `archived`. The next ensure reactivates it. - Migration `0437`: unique index `environments_managed_plugin_environment_idx` on `metadata ->> 'managedPluginEnvironment'`. This is the only schema change of the three pull requests. Snapshot `0432` is pruned to keep the five newest. - `server/src/routes/environments.ts`: `isPlatformProvisionedEnvironment` also covers the new marker (write floor and redaction). `managedPluginEnvironment` is a reserved marker key, so a client cannot stamp it on its own row. Nothing provisions marked rows yet, so the marker-clear escape hatch stays open for them. The next pull request refuses it while the managed config declares the row's entry. - `ui/src/lib/adapter-test-environment.ts`: `resolveManagedSandboxEnvironmentId` requires `driver === "sandbox"`. The plugin row also carries `managedByPaperclip`, so the old "not local" check could take it for the managed sandbox. ## Verification - `cd server && npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres): - The ensure creates the marked row through the plugin validator. A second ensure writes nothing and logs nothing. - A hand-made row for the same plugin and driver is adopted. It keeps its id and its agents. - Archive, then ensure, reactivates the row. - The ensure refuses while the plugin has no running worker, and refuses a name that another environment holds. - The unique index allows one row per marker. - `npx vitest run src/__tests__/environment-routes.test.ts`: on a cloud-managed instance, edit, env-var edit, status change and delete of a marked plugin row are refused, including for instance admins. The marker alone keeps the floor. A client cannot stamp the marker. The marker clear is allowed. - `npx vitest run src/__tests__/environment-service.test.ts`: passes. - `cd ui && npx vitest run src/lib/adapter-test-environment.test.ts`: the plugin row is never taken for the managed sandbox. Under managed-sandbox-only, an agent on the sandbox tests in the sandbox, and an agent on the plugin environment tests there. - `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter @paperclipai/db check:migrations` pass. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. `pnpm verify` asks for broader verification because of the migration. ## Risks - Migration: one new partial unique index on a small table. No row carries the marker before this change, so the index cannot fail on existing data. - No caller uses the new ensure yet, so no instance changes behavior. - The UI change affects only lists that hold a non-sandbox row with `managedByPaperclip`. Today that is only the local row, which the old check already excluded. > 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.9 KiB
TypeScript
129 lines
5.9 KiB
TypeScript
import type { Environment } from "@paperclipai/shared";
|
|
|
|
/**
|
|
* The managed-sandbox-only policy hides the local environment and runs every
|
|
* agent in the managed sandbox, but no managed sandbox environment is
|
|
* available. The Test resolution throws this error so the Test surfaces a
|
|
* fail-closed message, the same way a real run fails closed. The Test must not
|
|
* fall back to a local host probe, because a host probe would report a result
|
|
* for a target the real run never uses.
|
|
*/
|
|
export class ManagedSandboxUnavailableForTestError extends Error {
|
|
constructor() {
|
|
super(
|
|
"This instance runs agents only in its managed sandbox environment, but no " +
|
|
"managed sandbox environment is available to test in. Check that the managed " +
|
|
"sandbox provider is active, then retry the test.",
|
|
);
|
|
this.name = "ManagedSandboxUnavailableForTestError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which environment should an adapter "Test" probe?
|
|
*
|
|
* The resolution mirrors the server run-time resolution
|
|
* (`resolveExecutionWorkspaceEnvironmentId`) across all three tiers: the
|
|
* agent's own environment wins, otherwise the instance default, otherwise the
|
|
* instance local-default environment. The server always resolves a run to one
|
|
* of these three tiers, so the Test must probe the same target. Without the
|
|
* local-default tier the Test would send no environment id and probe the
|
|
* Paperclip host, even though a real run resolves to the local-default
|
|
* environment. The two paths must match, so a Test result reflects a real run.
|
|
*
|
|
* The managed-sandbox-only policy (`enableManagedSandboxOnly`) redirects a
|
|
* resolution that lands on the local environment to the managed sandbox
|
|
* environment, and it fails closed when no managed sandbox exists. This
|
|
* function mirrors that redirect. A resolution that lands on the local
|
|
* environment — the local tier, or no tier at all because the policy hides the
|
|
* local tier — resolves to the managed sandbox environment instead. With no
|
|
* managed sandbox environment the function throws, so the Test does not probe
|
|
* the local host that the real run rejects. A resolution that lands on a
|
|
* non-local environment (an agent or instance default that names an ssh or a
|
|
* user sandbox) is untouched, because the policy hides local, it does not
|
|
* forbid other environments.
|
|
*
|
|
* The policy hides the local environment from the client, so the resolved id
|
|
* can name a local row the client cannot see (an agent default that still
|
|
* points at the hidden local row). The `visibleEnvironmentIds` list closes that
|
|
* gap: under the policy a resolved id that names no visible environment is the
|
|
* hidden local row, or a stale reference, so the function redirects it to the
|
|
* managed sandbox too. Omit the list to skip this visibility check.
|
|
*/
|
|
export function resolveAdapterTestEnvironmentId(input: {
|
|
agentDefaultEnvironmentId: string | null | undefined;
|
|
instanceDefaultEnvironmentId: string | null | undefined;
|
|
localDefaultEnvironmentId: string | null | undefined;
|
|
managedSandboxOnly?: boolean;
|
|
managedSandboxEnvironmentId?: string | null | undefined;
|
|
visibleEnvironmentIds?: readonly string[] | null | undefined;
|
|
}): string | null {
|
|
const resolved =
|
|
input.agentDefaultEnvironmentId ||
|
|
input.instanceDefaultEnvironmentId ||
|
|
input.localDefaultEnvironmentId ||
|
|
null;
|
|
if (input.managedSandboxOnly !== true) {
|
|
return resolved;
|
|
}
|
|
const localDefaultId = input.localDefaultEnvironmentId || null;
|
|
const resolvedNamesVisibleEnvironment =
|
|
input.visibleEnvironmentIds == null ||
|
|
resolved === null ||
|
|
input.visibleEnvironmentIds.includes(resolved);
|
|
const landsOnLocal =
|
|
resolved === null || resolved === localDefaultId || !resolvedNamesVisibleEnvironment;
|
|
if (!landsOnLocal) {
|
|
return resolved;
|
|
}
|
|
if (!input.managedSandboxEnvironmentId) {
|
|
throw new ManagedSandboxUnavailableForTestError();
|
|
}
|
|
return input.managedSandboxEnvironmentId;
|
|
}
|
|
|
|
/**
|
|
* Find the active managed sandbox environment id in an environment list. The
|
|
* platform provisioner stamps the managed environment with
|
|
* `metadata.managedByPaperclip` (see `isPlatformManagedEnvironment`). The
|
|
* local-default environment and managed plugin environment rows also carry
|
|
* that stamp, so this function requires the `sandbox` driver and never
|
|
* mistakes a plugin row for the managed sandbox. The function selects only an `active` environment, never an `archived` one, so the Test
|
|
* resolution matches the server run-time resolver that requires an active
|
|
* managed sandbox (see `findManagedSandboxEnvironment`). The Test resolution
|
|
* uses this id as the redirect target under the managed-sandbox-only policy. The
|
|
* function returns `null` when the list holds no active managed sandbox
|
|
* environment.
|
|
*/
|
|
export function resolveManagedSandboxEnvironmentId(
|
|
environments: readonly Environment[] | null | undefined,
|
|
): string | null {
|
|
if (!environments) return null;
|
|
const managed = environments.find(
|
|
(environment) =>
|
|
environment.driver === "sandbox" &&
|
|
environment.status === "active" &&
|
|
environment.metadata?.managedByPaperclip === true,
|
|
);
|
|
return managed?.id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Find the instance local-default environment id in an environment list. The
|
|
* server auto-creates one `local` driver environment and stamps it with
|
|
* `metadata.defaultForInstance: true` (see `ensureLocalEnvironment`). The Test
|
|
* resolution uses this id as the final tier, so it probes the same environment
|
|
* a real run resolves to when neither an agent default nor an instance default
|
|
* is set. The function returns `null` when the list holds no such environment.
|
|
*/
|
|
export function resolveLocalDefaultEnvironmentId(
|
|
environments: readonly Environment[] | null | undefined,
|
|
): string | null {
|
|
if (!environments) return null;
|
|
const local = environments.find(
|
|
(environment) =>
|
|
environment.driver === "local" && environment.metadata?.defaultForInstance === true,
|
|
);
|
|
return local?.id ?? null;
|
|
}
|