Commit Graph
2 Commits
Author SHA1 Message Date
Nicky LeachandClaude Opus 5.5 913102870d fix(environments): fill the managed instance default once, under a lock (#19632)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - On a managed instance under `enableManagedSandboxOnly`, boot points
the instance default at the managed sandbox when nothing deliberate is
set
> - That write read the default and wrote it later without a lock. Two
overlapping boots, or a boot and an admin write, could overwrite a value
the other had just committed
> - A revert path also reset that stamped default whenever the mode was
turned off, so a platform-filled default could change again later
> - A planned plugin-environment default should fill only an unset
default and never change a set one, which needs every instance's default
to stay set
> - This pull request makes the fill one locked transaction and removes
the revert, so a filled default is an ordinary set value
> - The benefit is a single, race-free rule: boot fills an unset default
once, and nothing changes a set default afterwards

## Linked Issues or Issue Description

Refs #19551 (the plugin-environment default that builds on this rule)

**Subsystem affected**
Server environments: the managed environment bootstrap and the instance
settings route.

**Problem or motivation**
`applyManagedEnvironments` read the instance default, decided, and wrote
it in separate steps with no lock, so a concurrent boot or admin write
could be overwritten. When `enableManagedSandboxOnly` was not declared,
a revert reset a default the platform had filled. That needed a
`managedDefaultStamped` marker, plus a marker clear on every admin
default write. A later boot could therefore change a default that had
already been set.

**Proposed solution**
Fill the default in one transaction that locks the instance default row
and rechecks it. Fill only an unset, local or deleted default. Remove
the revert and its marker, so a filled default behaves like any other
set value.

**Alternatives considered**
Keeping the revert and adding locks to it. A revert on a mode change
would still move an existing instance's default later, which a fill-only
plugin default cannot tolerate.

**Roadmap alignment**
This narrows existing managed-environment behaviour. It adds no product
surface.

## What Changed

- New `server/src/services/managed-instance-default.ts`:
`fillUnsetInstanceDefault(db, environmentId)`. In one transaction it
creates the settings row if needed, locks the instance default row (`FOR
UPDATE`), and rechecks the current value. It writes only when the
default is unset, names the local environment, or names a deleted
environment. Any other set default is kept.
- `server/src/services/managed-environments.ts`:
- Under `enableManagedSandboxOnly`, the managed sandbox fill now uses
that helper, through a new `fillInstanceDefault` test seam.
- The mode-off revert and the `managedDefaultStamped` marker are
removed.
- The unused `instanceSettings` seam option is removed, along with the
`getById`, `update` and `findManagedSandboxEnvironment` methods of the
environments seam.
- `server/src/routes/instance-settings.ts`: an explicit default write is
a plain `update` again. The marker clear and its transaction are gone.
- `doc/DEVELOPING.md`: the "Managed environments" section states the
fill rule.

Existing rows may still carry `managedDefaultStamped` in their metadata.
Nothing reads it any more, and it is not part of the managed stock hash.

## Verification

- `cd server && npx vitest run
src/__tests__/managed-instance-default.test.ts` (embedded Postgres):
- The fill writes an unset default, including on a fresh instance with
no settings row. It also writes a default that names the local
environment, or one whose environment was deleted.
- It never changes a default set to an ssh, plugin or other sandbox
environment, and leaves a default that already names the target alone.
- Controlled overlap: an uncommitted admin default write makes the fill
wait, and the fill then keeps the admin's value. This test fails without
the row lock.
  - Two overlapping fills: exactly one writes.
- Real boot pass: the default is filled under managed-sandbox-only and
stays the same after a boot with the mode off. A set default stays on
every boot.
- `npx vitest run src/services/managed-environments.test.ts
src/services/managed-environments-plugin-entries.test.ts`: the fill runs
only under managed-sandbox-only, with the managed sandbox row. It never
runs for a plugin environment. Nothing is written when the mode is off.
A failed fill does not fail the ensure.
- `npx vitest run src/__tests__/instance-settings-routes.test.ts`: an
explicit default write is a plain update that touches no environment
row.
- `npx vitest run src/__tests__/managed-plugin-environments.test.ts
src/__tests__/environment-routes.test.ts
src/__tests__/server-startup-feedback-export.test.ts
src/__tests__/instance-settings-service.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

- Behaviour change: when `enableManagedSandboxOnly` is turned off for an
instance, a default the platform filled with the managed sandbox now
stays. Local execution is still allowed then, but it is no longer the
default unless an admin picks it.
- No schema change. Stale `managedDefaultStamped` keys stay in some row
metadata and are ignored.
- The fill holds the instance default row lock for one short transaction
at boot.

> 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 15:01:58 -08:00
Nicky LeachandClaude Opus 5.5 98ebeefbc9 feat(environments): let managed config declare plugin environments (#19508)
## 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
> - #19490 added the plugin environment rows, their marker index and
write floor, but nothing creates them yet
> - The managed config still accepts only one sandbox entry, so a
managed instance cannot get a plugin-provided environment without manual
setup
> - This pull request lets the managed config declare plugin entries
beside the sandbox entry, and provisions one locked row for each at boot
> - The benefit is that a managed instance gets its plugin environments
with no manual setup, and the rows recover by themselves when their
plugin restarts

## Linked Issues or Issue Description

Refs #19490 (the plugin environment rows this pull request provisions)

**Subsystem affected**
Server environments: the managed-config parser, the managed environment
bootstrap, and the environment routes' write floor.

**Problem or motivation**
A managed instance cannot get a plugin environment from its managed
config. The `environments` section accepts one entry, and that entry
always provisions the managed sandbox. Company settings cannot create a
plugin environment either. So each managed instance that needs one
requires manual setup.

**Proposed solution**
Let an `environments` entry declare `driver: "plugin"`. Boot provisions
one locked row for each plugin entry through the row layer from #19490.
A later pull request adds a one-time instance default for one plugin
entry, and advertises the new managed-config capability.

**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

- `server/src/services/managed-config.ts`: an `environments` entry may
set `driver` to `"sandbox"` or `"plugin"`. An entry without `driver` is
still the sandbox entry, so today's documents parse unchanged.
- A plugin entry has `name`, optional `description`, `pluginKey` (the
plugin's manifest id), `driverKey` and optional `driverConfig`.
- At most one sandbox entry. Names are unique across the section, and
`pluginKey`–`driverKey` pairs are unique. Secret-like keys in
`driverConfig` refuse startup, as for sandbox `config`. An unknown key
or driver still refuses startup, and that includes `instanceDefault`
until the follow-up pull request.
- Plugin entries parse into a new `pluginEnvironments` list. The
`environments` list keeps only the sandbox entry, so its consumers do
not change.
  - `MANAGED_ENVIRONMENT_DRIVERS` lists the accepted drivers.
- `server/src/services/managed-environments.ts`
(`applyManagedEnvironments`):
- Boot refuses to start unless an auto-installed catalog entry installs
each plugin entry's `pluginKey`. `plugins.autoInstall` holds catalog
keys, not manifest ids, so the parser cannot check this. Boot uses the
manifest ids `createApp` already resolves from the bundled and
distribution catalogs (`app.locals.managedBundledPluginKeys`, passed in
by `server/src/index.ts`). Without that list it resolves the catalog
itself.
- Plugin entries run after the sandbox entry and reuse its readiness
handling. Boot awaits `pluginsReady`, then requires the plugin `ready`,
declaring the entry's driver, and running a worker. Otherwise boot
archives the row and re-ensures it on the worker's `ready`.
- Recovery re-ensures on every worker `ready` until one ensure succeeds
or the plugin rejects the config (HTTP 422).
- If the ensure fails because the worker died, boot archives the row and
waits for `ready`. If the worker is still running (for example the call
timed out), boot keeps the row as it is, retries once at once and on the
next `ready`, and otherwise at the next boot. No timer is added.
  - Removing an entry never archives its row.
- A plugin-only document does not conflict with
`PAPERCLIP_EXECUTION_MODE`, because plugin rows do not use the sandbox
slot.
- Plugin entries never change the instance default. The sandbox stamp
still fills only an unset or local default, so it never replaces a
plugin default.
- `server/src/routes/environments.ts`: `isPlatformSlotEnvironment`
treats a marked plugin row as a live slot while the document declares
its entry. So the marker-clear escape hatch is refused then, and allowed
again after the entry leaves the document.
- `doc/DEVELOPING.md`: new "Managed environments" section.

## Verification

- `cd server && npx vitest run src/__tests__/managed-config.test.ts`:
old documents parse unchanged. Sandbox plus plugin parses. Each of these
refuses startup: two sandbox entries, `driver: null`, duplicate names, a
duplicate pair, a secret-like key, an unknown key (including
`instanceDefault`), an unknown driver, a malformed `driverKey` or
`driverConfig`.
- `npx vitest run
src/services/managed-environments-plugin-entries.test.ts` (the
sandbox-entry tests stay in `managed-environments.test.ts`; shared
fixtures are in `managed-environments.test-support.ts`):
- A plugin entry is ensured with the validator's worker manager, after
the sandbox entry.
- Boot refuses a plugin entry whose `pluginKey` no auto-installed
catalog entry installs, both from the passed list and from its own
catalog lookup.
- A plugin that is down archives the row, and worker recovery re-ensures
it. A missing plugin, a plugin without the driver, or a worker manager
without `call` counts as unavailable.
- Recovery stays armed after a failed attempt and stops after one
success or a permanent config rejection (422). A `ready` during an
attempt queues one more.
- A worker that dies during the boot ensure archives the row and arms
recovery. A failure while the worker keeps running leaves the row as it
is and retries.
- Removing an entry does not archive. Plugin entries and the sandbox
stamp never change a plugin default.
  - An ensure failure is counted, not thrown.
- `npx vitest run src/__tests__/managed-plugin-environments.test.ts`
(embedded Postgres, real `applyManagedEnvironments`): the first boot
creates the row and the second changes nothing. A plugin that is down
archives the row, and recovery brings it back. Removing the entry does
not archive the row. The instance default stays unset. The row tests
from #19490 still pass.
- `npx vitest run src/__tests__/environment-routes.test.ts`: the marker
clear is refused while the document declares the entry, and allowed
after it leaves.
- `npx vitest run src/__tests__/instance-settings-service.test.ts
src/__tests__/instance-settings-cloud-defaults.test.ts`: pass with the
new `pluginEnvironments` field.
- `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

- No schema change.
- A managed-config document with a plugin entry now provisions a row at
boot. A document without one behaves as before.
- Adoption renames an unmarked row for the same plugin and driver to the
declared name and resets its driver config to the declared one. The row
keeps its id, agents and other metadata, and it is then locked like the
managed sandbox.
- Boot now refuses to start when a plugin entry names a plugin that no
auto-installed catalog entry provides. This is the same fail-closed
posture as an unknown sandbox provider.

> 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>
2026-11-06 17:35:13 +00:00