feat(server): computed owner instance-admin elevation for cloud-managed instances, behind platform floors (#10343)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Cloud-managed instances authenticate tenant users through a
trusted-header path (`resolveCloudTenantActor`) that deliberately never
grants `instance_admin`, so every tenant user is company-scoped
> - On a dedicated (single-owner) managed instance that leaves the
paying owner unable to administer their own instance: instance settings,
the environments admin surface, and the custom sandbox image flow are
all instance-admin gated (the environments UI can't even show the
provider/image of the platform sandbox because the restricted read view
blanks `config` entirely)
> - Re-granting the old blanket `instance_user_roles` row would repeat
the mistake the shared-pool hardening fixed: DB rows go stale, resurrect
via restores, and elevate through every auth path
> - This pull request elevates only the stack `owner`, computed per
request at the trusted-header boundary behind a new managed-tier feature
flag, and ships that elevation together with code floors on the
platform-owned surfaces an instance admin must not control on a managed
instance
> - The benefit is that dedicated-stack owners can administer their own
instance while platform credentials, execution policy, backups, and
runtime code-install stay platform-owned, and self-hosted behavior is
unchanged

## Linked Issues or Issue Description

No public GitHub issue exists for this change; the underlying issue is
described here following the feature-request template.

### Problem or motivation

- On cloud-managed instances, tenant users resolved from trusted headers
are always company-scoped. For dedicated instances with a single paying
owner, the owner cannot reach any instance-admin surface of their own
instance (instance settings, environments administration, custom image
setup), and the restricted environment read view hides even structural
fields like the sandbox provider and image.
- The previous hardening intentionally removed blanket elevation (and
purges stale `instance_user_roles` rows on every trusted-header
authentication). That protection must not regress for shared
multi-tenant pools.

### Proposed solution

- Owner-only, computed, flag-gated elevation plus code floors on
platform-owned surfaces, in one PR so the elevation can never ship
without the floors.

### Alternatives considered

- Re-inserting an `instance_user_roles` row for owners (the
pre-hardening model): rejected — DB rows go stale, survive restores, and
elevate through every auth path; #7525 removed exactly this.
- Widening only the environments read view without any elevation:
rejected — it fixes one screen but still leaves a dedicated-instance
owner unable to administer instance settings or custom images.
- Elevating additional stack roles (`member`/`admin`/`support`):
rejected — only the owner has an ownership claim over the whole
instance; other roles stay company-scoped.

### Roadmap alignment

- Extends the shipped "Cloud deployments" roadmap work (multi-tenant
isolation, company-scoped cloud tenants, managed-instance bootstrap)
without overlapping planned core items, and leaves self-hosted behavior
unchanged.

## What Changed

- **New feature key** `enableOwnerInstanceAdmin` (`packages/shared`):
boolean flag in `instanceExperimentalSettingsSchema`, catalog tier
`managed`, `cloudDefault: true`, `selfHostedDefault: false`. Inert on
self-hosted instances — the elevation path only exists behind the cloud
tenant trust token.
- **Computed elevation** (`server/src/middleware/auth.ts`):
`resolveCloudTenantActor` now returns `isInstanceAdmin: true` only when
the trusted-header stack role is `owner` **and** the flag is enabled.
The flag is resolved through the instance-settings service so the
managed-config overlay applies (the control plane can disable elevation
fleet-wide without touching tenant databases; a DB row edit or restore
cannot resurrect it). Resolution fails closed on settings read errors.
The `instance_user_roles` never-insert and the per-request stale-row
purge are byte-identical. `member`/`admin`/`support` stack roles stay
company-scoped.
- **Authorization guard split**
(`server/src/services/authorization.ts`): the blanket-allow now trusts
the actor's *computed* `isInstanceAdmin` flag (only the attested
resolver can set it for `cloud_tenant` actors) while keeping the
`instance_user_roles` DB lookup excluded for `cloud_tenant` — a stale or
hand-inserted role row still elevates nothing.
- **Floor F1 — platform environment credentials**
(`server/src/routes/environments.ts`): on cloud-managed instances,
platform-provisioned environment rows (`managedByPaperclip` marker, plus
the legacy managed-Kubernetes marker) use a single floored view for
every reader on all environment routes (list, get, create, update,
delete responses): `envVars` are never echoed and credential-shaped
`config` keys (reusing the managed-config
`SECRET_LIKE_CONFIG_KEY_PATTERN`) are dropped — for **all** actors
including instance admins — while structural config (provider, image,
template, region, …) and the managed markers stay visible. This also
fixes the environments UI for managed sandboxes, which previously lost
the provider/image entirely in the restricted view. The floor also
covers writes: `PATCH /environments/:id` and `DELETE /environments/:id`
on a platform-provisioned row are rejected (403,
`environment_platform_managed`) for every actor including instance
admins, and the guard binds to the persisted row's markers so a patch
cannot strip the managed marker to lift the floor. The one recovery path
is a metadata-only PATCH that solely clears the marker keys
(null/false), for rows stamped through the old unrestricted API before
the markers became reserved — and it never applies to a row whose slot
markers are live platform state: the single local row
(`environments_local_driver_idx`), which `ensureLocalEnvironment` adopts
and stamps on cloud-managed instances from every caller (company
creation, the heartbeat, run orchestration), and the single marked
sandbox row (`environments_managed_sandbox_idx`) while a managed-sandbox
bootstrap path is configured (managed-config `environments` section or
`PAPERCLIP_EXECUTION_MODE=kubernetes`) and the provisioner therefore
adopts and refreshes it on every boot. Clearing a live slot row's
markers would let the next write reclassify it as tenant-managed and
bypass the floor; conversely, when no sandbox provisioning path is
configured the platform holds no claim on any sandbox row, so a platform
marker there is stale by definition and the recovery patch applies.
Every marker outside a live slot is clearable, so no legacy row is ever
locked permanently. Custom-image setup and probes on the platform
sandbox stay available to instance admins — those are the owner-facing
flows this elevation exists for. The marker keys themselves are
reserved: client create/update payloads that set `managedByPaperclip` or
`managedKubernetesSandbox` are rejected (422,
`environment_platform_marker_reserved`) on cloud-managed instances, so a
tenant row can never be stamped platform-provisioned through the API and
self-locked behind the write floor (the provisioner writes markers at
the service layer, not through these routes). Tenant-created
environments are otherwise unaffected.
- **Floor F2 — executionMode**
(`server/src/routes/instance-settings.ts`): on cloud-managed instances,
`PATCH /instance/settings/general` rejects writes that would change
`executionMode` (403, `execution_mode_platform_managed`). Same-value
echoes pass so settings forms that submit the full general-settings
object keep working. The boot-time execution-policy bootstrap path is
untouched (it calls the service directly).
- **Floor F3 — manual database backups**
(`server/src/routes/instance-database-backups.ts`): floored off on
cloud-managed instances (403, `database_backups_platform_managed`);
backups are platform-owned there, and the result would also echo a
server-side filesystem path.
- **Floor F4 — adapter code install** (`server/src/routes/adapters.ts`):
`POST /adapters/install` and `POST /adapters/:type/reinstall` are
floored off on cloud-managed instances (403,
`adapter_install_platform_managed`). Adapter packages execute in the
server process, so a runtime install would let an instance admin read
the platform trust anchors out of the process environment. This mirrors
the existing bundled-only plugin install floor; adapter code on managed
instances comes bundled with the platform image.

## Instance-admin surface audit

Before widening who can hold `isInstanceAdmin`, every
instance-admin-gated surface in `server/src` was enumerated and reviewed
for whether its response or side effects could echo process environment
values or platform credentials (tenant trust token, JWT signing keys,
database connection strings, provider API keys): 29 distinct gate
definitions covering ~90+ call sites, in four groups — sole
instance-admin gates (12), instance-admin-or-company-permission gates
(10), response-shaping/scope-widening sites (6), and the central
`allow_instance_admin` short-circuit in the authorization service (58
`decide()` call sites).

Findings and dispositions:
- **Environment read/write responses** exposed platform sandbox
`envVars`/credential-shaped config to instance admins → closed by floor
F1.
- **Manual backup trigger** echoed a server filesystem path and triggers
a platform-owned operation → closed by floor F3.
- **Adapter install/reinstall** loads externally fetched code into the
server process (indirect, complete env exposure) → closed by floor F4.
The sibling plugin-install path already had a bundled-only floor on
managed instances and needed no change.
- **Token-minting surfaces** (gateway tokens, custom-image
terminal/connection tokens) mint credentials scoped to the instance's
own resources, not platform trust anchors → acceptable for an
owner-admin of a dedicated instance; unchanged.
- All remaining gated surfaces return ordinary instance-scoped business
data; none echo `process.env` or platform secrets directly. OAuth client
secrets are referenced by env-var *name* only; SSH private keys are
stored as secret refs before persistence and are not echoed.

Operational note for managed platforms: this model assumes the process
environment of a managed instance holds only that instance's own
credentials. Platform operators should keep provider credentials
per-instance (never fleet-shared) since an instance admin ultimately
controls in-process code on their own instance.

## Verification

- `pnpm vitest run server/src/middleware/cloud-tenant-actor.test.ts` —
resolver matrix: owner × flag on/off, flag via managed overlay
(on-over-DB-off and off-over-DB-on), member/admin/support × flag on,
no-token self-hosted, fail-closed settings read, purge still runs and no
role row is ever inserted (14 tests).
- `pnpm vitest run server/src/__tests__/authorization-service.test.ts` —
computed flag elevates a `cloud_tenant` actor; a stale
`instance_user_roles` row still never does; `session` actors unchanged
(full suite, embedded Postgres).
- `pnpm vitest run server/src/__tests__/environment-routes.test.ts` —
F1: no secret echo to admins on get/list, structural config visible to
restricted readers, platform-row PATCH/DELETE rejected for admins
(including a marker-stripping patch), marker-clear recovery allowed for
stale legacy rows and for a marked sandbox row when no provisioning path
is configured, but refused on the managed local row and on the sandbox
slot row under a managed-config `environments` entry or the forced
kubernetes execution mode, client marker-stamping creates/patches
rejected, tenant rows still readable and writable, self-hosted
read+write regression (60 tests).
- `pnpm vitest run server/src/__tests__/environment-service.test.ts` —
`ensureLocalEnvironment` adopts a pre-existing local row on
cloud-managed instances (marker stamped, other metadata preserved,
idempotent — no rewrite on re-ensure) and leaves self-hosted rows
untouched (22 tests, embedded Postgres).
- `pnpm vitest run server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-database-backups-routes.test.ts` — F2
change-vs-echo matrix incl. self-hosted regression; F3 floor for both
admin shapes (32 tests).
- `pnpm vitest run server/src/__tests__/adapter-routes-authz.test.ts` —
F4 floor; self-hosted install/reinstall behavior unchanged (existing
cases).
- `pnpm vitest run server/src/__tests__/first-admin-claim.test.ts
server/src/__tests__/bootstrap-claim-routes.test.ts
server/src/__tests__/managed-config.test.ts
server/src/__tests__/health.test.ts
server/src/__tests__/instance-settings-managed-overlay.test.ts
server/src/services/managed-environments.test.ts
server/src/services/execution-policy-bootstrap.test.ts` — first-admin
bootstrap gate and managed-config behavior unchanged (91 tests).
- `pnpm vitest run packages/shared/src/feature-catalog.test.ts` —
catalog/schema sync tests cover the new key (selfHostedDefault must
equal the schema default).
- `pnpm run typecheck` — all 31 workspace projects clean.

## Risks

- Self-hosted behavior is unchanged: every floor binds to
`isCloudManagedInstance()` (tenant trust token present), the new flag
defaults off with no elevation path, and regression tests pin the
self-hosted branches.
- The elevation is fail-closed and stateless: turning the flag off
(managed overlay or DB) de-elevates on the next request; there is no
role row to clean up and restores cannot resurrect elevation.
- On a cloud-managed instance a pre-existing unmarked local row is
adopted (stamped `managedByPaperclip`) by the next ensure and becomes
platform-owned — the intended managed-product semantic: the platform
owns the single local slot. Self-hosted instances are untouched.
- F1 widens restricted readers' view of platform-provisioned rows from
fully blanked `config`/`metadata` to structural-only `config` plus
markers. Platform-delivered config is guaranteed secret-free by the
managed-config contract (secret-shaped keys fail startup), and the floor
re-drops secret-shaped keys defensively.
- One extra instance-settings read per trusted-header request for
owner-role actors (the resolver already performs several queries per
request).

## Model Used

Claude Fable 5 (Anthropic) — model id `claude-fable-5`, extended
thinking enabled, agentic tool use via Claude Code; read-only explore
subagents on the same model were used for the surface audit sweep.

## 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
- [ ] 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>
This commit is contained in:
Devin Foley 2026-07-27 18:58:31 -07:00 committed by GitHub
parent c3bd0c5d50
commit c274f10abc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1224 additions and 24 deletions

View File

@ -229,6 +229,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: true,
selfHostedDefault: true,
},
enableOwnerInstanceAdmin: {
title: "Owner Instance Admin",
description:
"On cloud-managed instances, grant the stack owner instance-admin access to their own dedicated instance. Elevation is computed at the trusted-header auth boundary; no instance admin role rows are created. Inert on self-hosted instances.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: false,
},
enableWorktreeRunExecution: {
title: "Worktree Run Execution",
description:

View File

@ -69,6 +69,14 @@ export interface InstanceExperimentalSettings {
enableIssueGraphLivenessAutoRecovery: boolean;
enableWorkspaceBranchReconcileForward: boolean;
enableWorkspaceDirtyQuarantineRepair: boolean;
/**
* On cloud-managed instances, grant the stack owner instance-admin access
* to their own dedicated instance. Elevation is computed per request at the
* trusted-header auth boundary (owner stack role + this flag); no
* `instance_user_roles` row is ever written. Inert on self-hosted
* instances, which have no trusted cloud tenant path.
*/
enableOwnerInstanceAdmin: boolean;
/**
* Worktree preview instances (`PAPERCLIP_IN_WORKTREE=true`) suppress the
* heartbeat run engine by default so previews never self-execute tasks. When

View File

@ -63,6 +63,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
enableOwnerInstanceAdmin: z.boolean().default(false),
enableWorktreeRunExecution: z.boolean().default(false),
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null),

View File

@ -340,4 +340,31 @@ describe.sequential("adapter management route authorization", () => {
expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled();
},
);
describe("cloud-managed adapter code install floor", () => {
beforeEach(() => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
});
it.each(["install", "reinstall"] as const)(
"floors adapter %s off for instance admins on cloud-managed instances",
async (routeName) => {
resetInstalledExternalAdapterState();
if (routeName !== "install") {
seedInstalledExternalAdapter();
}
const app = createApp(instanceAdmin);
const res = await sendMutatingRequest(app, routeName);
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details).toMatchObject({ code: "adapter_install_platform_managed" });
expect(mocks.execFile).not.toHaveBeenCalled();
expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled();
},
);
});
});

View File

@ -1492,6 +1492,39 @@ describeEmbeddedPostgres("authorization service", () => {
expect(sessionDecision).toMatchObject({ allowed: true, reason: "allow_instance_admin" });
});
it("trusts the computed isInstanceAdmin flag on cloud_tenant actors", async () => {
// The trusted-header resolver is the only code path that can set
// isInstanceAdmin on a cloud_tenant actor (stack owner +
// enableOwnerInstanceAdmin). The authorization service must honor the
// computed flag without consulting instance_user_roles.
const tenantCompany = await createCompany(db, "CloudTenantOwnerAdmin");
const otherCompany = await createCompany(db, "CloudTenantOwnerAdminOther");
const userId = `user-${randomUUID()}`;
const targetAgent = await createAgent(db, otherCompany.id, { role: "engineer" });
await db.insert(companyMemberships).values({
companyId: tenantCompany.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "owner",
});
// Deliberately NO instanceUserRoles row: elevation is computed, not stored.
const decision = await authorizationService(db).decide({
actor: {
type: "board",
userId,
companyIds: [tenantCompany.id],
isInstanceAdmin: true,
source: "cloud_tenant",
},
action: "tasks:assign",
resource: { type: "issue", companyId: otherCompany.id, assigneeAgentId: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(decision).toMatchObject({ allowed: true, reason: "allow_instance_admin" });
});
it("denies simple-mode assignment to a target agent from another company", async () => {
const sourceCompany = await createCompany(db, "AssignmentSource");
const targetCompany = await createCompany(db, "AssignmentTarget");

View File

@ -1,7 +1,7 @@
import type { Server } from "node:http";
import express from "express";
import request from "supertest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { environmentRoutes } from "../routes/environments.js";
import { errorHandler } from "../middleware/index.js";
@ -27,6 +27,7 @@ const mockProjectService = vi.hoisted(() => ({
const mockInstanceSettingsService = vi.hoisted(() => ({
listCompanyIds: vi.fn(),
getGeneral: vi.fn(),
}));
const mockEnvironmentService = vi.hoisted(() => ({
@ -228,6 +229,8 @@ describe("environment routes", () => {
mockProjectService.getById.mockReset();
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockInstanceSettingsService.getGeneral.mockReset();
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentService.list.mockReset();
mockEnvironmentService.list.mockResolvedValue([]);
mockEnvironmentService.getById.mockReset();
@ -383,6 +386,434 @@ describe("environment routes", () => {
});
});
describe("platform-provisioned environment floor on cloud-managed instances", () => {
function createPlatformSandboxEnvironment() {
const now = new Date("2026-04-16T05:00:00.000Z");
return {
id: "env-managed-1",
companyId: "company-1",
name: "Daytona",
description: "Managed sandbox environment",
driver: "sandbox",
status: "active" as const,
config: {
provider: "daytona",
image: "custom-image:latest",
target: "us",
apiKey: "must-never-echo",
},
envVars: { DAYTONA_API_KEY: "must-never-echo" },
metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" },
createdAt: now,
updatedAt: now,
};
}
const ownerAdminActor = {
type: "board",
userId: "owner-1",
source: "cloud_tenant",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "owner" }],
isInstanceAdmin: true,
};
// A minimal valid managed-config document whose `environments` entry makes
// the managed-sandbox provisioner own the marked sandbox slot row.
const MANAGED_CONFIG_WITH_SANDBOX_ENTRY = JSON.stringify({
v: 1,
mode: "cloud",
catalogVersion: "2026.720.0",
features: {},
plugins: { autoInstall: [] },
environments: [{ name: "Sandbox", provider: "daytona" }],
});
beforeEach(() => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
});
it("never echoes env vars or credential-shaped config keys to instance admins", async () => {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app).get("/api/environments/env-managed-1");
expect(res.status).toBe(200);
expect(res.body.envVars).toEqual({});
expect(res.body.config).toEqual({
provider: "daytona",
image: "custom-image:latest",
target: "us",
});
expect(res.body.metadata).toMatchObject({ managedByPaperclip: true });
expect(JSON.stringify(res.body)).not.toContain("must-never-echo");
});
it("exposes structural config to restricted company readers instead of blanking it", async () => {
mockEnvironmentService.list.mockResolvedValue([createPlatformSandboxEnvironment()]);
const app = createApp({
type: "board",
userId: "user-2",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
isInstanceAdmin: false,
});
const res = await request(app).get("/api/companies/company-1/environments");
expect(res.status).toBe(200);
expect(res.body[0].config).toEqual({
provider: "daytona",
image: "custom-image:latest",
target: "us",
});
expect(res.body[0].envVars).toEqual({});
expect(res.body[0].metadata).toMatchObject({ managedByPaperclip: true });
});
it("rejects updates to platform-provisioned rows, including for instance admins", async () => {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("allows a marker-clear-only patch to unblock a row with a stale legacy kubernetes marker", async () => {
// A sandbox row carrying only the legacy wrapper marker does not hold
// the managed sandbox slot (`environments_managed_sandbox_idx` keys on
// `managedByPaperclip`), and with the persisted execution mode not
// forcing kubernetes nothing selects rows by that marker either — so
// the marker is a stale leftover, not live platform state.
const staleRow = {
...createPlatformSandboxEnvironment(),
id: "env-legacy-1",
metadata: { managedKubernetesSandbox: true },
};
mockEnvironmentService.getById.mockResolvedValue(staleRow);
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-legacy-1")
.send({ metadata: { managedKubernetesSandbox: false } });
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalled();
});
it("allows a marker-clear-only patch on a non-slot driver with a stale platform marker", async () => {
const staleRow = {
...createPlatformSandboxEnvironment(),
id: "env-stale-ssh-1",
driver: "ssh",
metadata: { managedByPaperclip: true },
};
mockEnvironmentService.getById.mockResolvedValue(staleRow);
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-stale-ssh-1")
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalled();
});
it("refuses the marker-clear patch on the sandbox slot row while managed provisioning is configured", async () => {
// With a managed-config `environments` entry, driver=sandbox +
// managedByPaperclip is THE provisioner-owned slot row, adopted and
// refreshed on every boot — clearing its markers would reclassify it
// tenant-managed and let the next PATCH/DELETE bypass the write floor.
process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_WITH_SANDBOX_ENTRY;
try {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-managed-1")
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
} finally {
delete process.env.PAPERCLIP_MANAGED_CONFIG;
}
});
it("refuses the marker-clear patch on the sandbox slot row under the forced kubernetes execution mode", async () => {
// PAPERCLIP_EXECUTION_MODE=kubernetes is the other bootstrap path that
// owns (adopts and refreshes) the single marked sandbox row.
process.env.PAPERCLIP_EXECUTION_MODE = "kubernetes";
try {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-managed-1")
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
} finally {
delete process.env.PAPERCLIP_EXECUTION_MODE;
}
});
it("refuses the marker-clear patch on a kubernetes-marked row while the persisted execution mode forces kubernetes", async () => {
// `findKubernetesEnvironment` selects sandbox rows by the legacy
// marker alone whenever the persisted executionMode forces kubernetes
// — including when the bootstrap env that seeded the setting is gone
// (rollback / config drift, which the heartbeat handles explicitly).
// Clearing the marker would declassify the live runtime row.
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" });
mockEnvironmentService.getById.mockResolvedValue({
...createPlatformSandboxEnvironment(),
id: "env-legacy-1",
metadata: { managedKubernetesSandbox: true },
});
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-legacy-1")
.send({ metadata: { managedKubernetesSandbox: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("refuses the marker-clear patch on the kubernetes-managed row when only the persisted execution mode remains forced", async () => {
// Drift variant with the fully-stamped managed row: no managed-config
// entry and no bootstrap env, but the persisted executionMode still
// forces kubernetes, so the marker keeps selecting this row for runs.
mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" });
mockEnvironmentService.getById.mockResolvedValue({
...createPlatformSandboxEnvironment(),
metadata: {
managedByPaperclip: true,
managedSandboxProvider: "kubernetes",
managedKubernetesSandbox: true,
},
});
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-managed-1")
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("allows the marker-clear patch on a marked sandbox row when no provisioning path is configured", async () => {
// Without a managed-config `environments` entry or a forced execution
// mode, nothing on this instance provisions a sandbox environment, so a
// platform marker on a sandbox row can only be a stale leftover of the
// old unrestricted API — the recovery hatch must apply or the row is
// locked forever.
const staleRow = createPlatformSandboxEnvironment();
mockEnvironmentService.getById.mockResolvedValue(staleRow);
mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} });
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-managed-1")
.send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } });
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalled();
});
it("refuses the marker-clear patch on the managed local row", async () => {
// The local slot needs no configuration check: on a cloud-managed
// instance `ensureLocalEnvironment` adopts and stamps the single local
// row from every caller, so its markers are always live platform state.
mockEnvironmentService.getById.mockResolvedValue({
...createPlatformSandboxEnvironment(),
id: "env-local-1",
driver: "local",
metadata: { managedByPaperclip: true, defaultForInstance: true },
});
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-local-1")
.send({ metadata: { managedByPaperclip: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("rejects non-marker-only patches to platform-provisioned rows even from instance admins", async () => {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-managed-1")
.send({ name: "Renamed", metadata: { managedByPaperclip: false } });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("rejects deletes of platform-provisioned rows, including for instance admins", async () => {
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp(ownerAdminActor);
const res = await request(app).delete("/api/environments/env-managed-1");
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled();
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
});
it("rejects creates that stamp platform markers so tenants cannot self-lock rows", async () => {
const app = createApp(ownerAdminActor);
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Fake managed",
driver: "sandbox",
config: { provider: "daytona" },
metadata: { managedByPaperclip: true },
});
expect(res.status).toBe(422);
expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" });
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
});
it("rejects tenant patches that stamp platform markers so the row cannot become locked", async () => {
const tenantEnvironment = {
...createPlatformSandboxEnvironment(),
id: "env-tenant-1",
metadata: { source: "manual" },
};
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
const app = createApp(ownerAdminActor);
const res = await request(app)
.patch("/api/environments/env-tenant-1")
.send({ metadata: { source: "manual", managedKubernetesSandbox: true } });
expect(res.status).toBe(422);
expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" });
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("accepts platform markers in client payloads on self-hosted instances", async () => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
const existing = {
...createPlatformSandboxEnvironment(),
id: "env-tenant-1",
metadata: { source: "manual" },
};
mockEnvironmentService.getById.mockResolvedValue(existing);
mockEnvironmentService.update.mockResolvedValue({
...existing,
metadata: { source: "manual", managedByPaperclip: true },
});
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
});
const res = await request(app)
.patch("/api/environments/env-tenant-1")
.send({ metadata: { source: "manual", managedByPaperclip: true } });
expect(res.status).toBe(200);
expect(mockEnvironmentService.update).toHaveBeenCalled();
});
it("still updates tenant-created environments for instance admins on cloud-managed instances", async () => {
const tenantEnvironment = {
...createPlatformSandboxEnvironment(),
id: "env-tenant-1",
metadata: { source: "manual" },
};
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
mockEnvironmentService.update.mockResolvedValue({ ...tenantEnvironment, name: "Renamed" });
const app = createApp(ownerAdminActor);
const res = await request(app).patch("/api/environments/env-tenant-1").send({ name: "Renamed" });
expect(res.status).toBe(200);
expect(res.body.name).toBe("Renamed");
expect(mockEnvironmentService.update).toHaveBeenCalled();
});
it("does not floor writes to platform-marked rows on self-hosted instances", async () => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
const existing = createPlatformSandboxEnvironment();
mockEnvironmentService.getById.mockResolvedValue(existing);
mockEnvironmentService.update.mockResolvedValue({ ...existing, name: "Renamed" });
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
});
const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" });
expect(res.status).toBe(200);
expect(res.body.name).toBe("Renamed");
});
it("leaves tenant-created environments unfloored for instance admins", async () => {
const tenantEnvironment = {
...createPlatformSandboxEnvironment(),
id: "env-tenant-1",
metadata: { source: "manual" },
};
mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment);
const app = createApp(ownerAdminActor);
const res = await request(app).get("/api/environments/env-tenant-1");
expect(res.status).toBe(200);
expect(res.body.envVars).toEqual({ DAYTONA_API_KEY: "must-never-echo" });
expect(res.body.config.apiKey).toBe("must-never-echo");
});
it("does not floor platform-marked rows on self-hosted instances", async () => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
});
const res = await request(app).get("/api/environments/env-managed-1");
expect(res.status).toBe(200);
expect(res.body.envVars).toEqual({ DAYTONA_API_KEY: "must-never-echo" });
expect(res.body.config.apiKey).toBe("must-never-echo");
});
});
it("rejects non-admin blast-radius reads for instance-scoped environments", async () => {
const app = createApp({
type: "board",

View File

@ -530,6 +530,79 @@ describeEmbeddedPostgres("environmentService leases", () => {
expect(rows[0]?.updatedAt.toISOString()).toBe(archivedAt.toISOString());
});
it("adopts a pre-existing local row on a cloud-managed instance by stamping the platform marker", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Acme",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
const [existing] = await db
.insert(environments)
.values({
name: "Tenant Local",
driver: "local",
status: "active",
config: { shell: "zsh" },
metadata: { owner: "operator" },
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
})
.returning();
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
try {
const adopted = await svc.ensureLocalEnvironment(companyId);
expect(adopted.id).toBe(existing?.id);
expect(adopted.name).toBe("Tenant Local");
expect(adopted.metadata).toEqual({ owner: "operator", managedByPaperclip: true });
// Re-ensuring an already-adopted row must not rewrite it.
const adoptedRow = await db
.select()
.from(environments)
.where(eq(environments.driver, "local"))
.then((rows) => rows[0]);
const reused = await svc.ensureLocalEnvironment(companyId);
expect(reused.metadata).toEqual({ owner: "operator", managedByPaperclip: true });
const reusedRow = await db
.select()
.from(environments)
.where(eq(environments.driver, "local"))
.then((rows) => rows[0]);
expect(reusedRow?.updatedAt.toISOString()).toBe(adoptedRow?.updatedAt.toISOString());
} finally {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
}
});
it("does not stamp the platform marker on self-hosted instances (regression)", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Acme",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(environments).values({
name: "Tenant Local",
driver: "local",
status: "active",
config: {},
metadata: { owner: "operator" },
createdAt: new Date(),
updatedAt: new Date(),
});
const ensured = await svc.ensureLocalEnvironment(companyId);
expect(ensured.metadata).toEqual({ owner: "operator" });
});
it("deduplicates concurrent default local environment creation", async () => {
const companyId = randomUUID();
await db.insert(companies).values({

View File

@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import {
instanceDatabaseBackupRoutes,
@ -146,4 +146,50 @@ describe("instance database backup routes", () => {
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: "Database backup already in progress" });
});
describe("cloud-managed floor", () => {
beforeEach(() => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
});
it("floors the manual trigger off for every instance admin on a cloud-managed instance", async () => {
const service = createBackupService();
const app = createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
},
service,
);
const res = await request(app).post("/api/instance/database-backups").send({});
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "database_backups_platform_managed" });
expect(service.runManualBackup).not.toHaveBeenCalled();
});
it("floors the manual trigger off for a computed cloud_tenant owner-admin", async () => {
const service = createBackupService();
const app = createApp(
{
type: "board",
userId: "owner-1",
source: "cloud_tenant",
isInstanceAdmin: true,
companyIds: ["company-1"],
},
service,
);
await request(app).post("/api/instance/database-backups").send({}).expect(403);
expect(service.runManualBackup).not.toHaveBeenCalled();
});
});
});

View File

@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
@ -631,4 +631,99 @@ describe("instance settings routes", () => {
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
describe("executionMode floor on cloud-managed instances", () => {
const adminActor = {
type: "board",
userId: "owner-1",
source: "cloud_tenant",
isInstanceAdmin: true,
companyIds: ["company-1"],
};
beforeEach(() => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
});
it("rejects a write that changes executionMode", async () => {
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
executionMode: "kubernetes",
});
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ executionMode: "any" });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "execution_mode_platform_managed" });
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("rejects pinning executionMode when the platform left it unrestricted", async () => {
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ executionMode: "kubernetes" });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("allows a same-value executionMode echo so full-object settings forms keep working", async () => {
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
executionMode: "kubernetes",
});
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ executionMode: "kubernetes", keyboardShortcuts: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
executionMode: "kubernetes",
keyboardShortcuts: true,
});
});
it("allows general-settings writes that do not touch executionMode", async () => {
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ keyboardShortcuts: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ keyboardShortcuts: true });
});
it("keeps executionMode writable on self-hosted instances", async () => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
const app = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
});
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ executionMode: "kubernetes" });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes" });
});
});
});

View File

@ -49,6 +49,7 @@ describe("instance settings service", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
enableOwnerInstanceAdmin: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,

View File

@ -17,6 +17,7 @@ import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@pap
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { boardAuthService } from "../services/board-auth.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { forbidden, unprocessable } from "../errors.js";
@ -379,15 +380,43 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
* Whether this instance is managed by a Paperclip Cloud control plane.
* When the tenant server token is configured, the control plane owns the
* user/identity lifecycle for this instance: users arrive through trusted
* headers (resolveCloudTenantActor) and are deliberately never granted
* instance_admin. Surfaces that assume a self-hosted operator will claim
* the instance (e.g. the first-admin bootstrap gate) should treat a
* cloud-managed instance as already set up.
* headers (resolveCloudTenantActor) and are deliberately never granted the
* `instance_admin` DB role. The only elevation a cloud tenant can carry is
* computed per request at the trusted-header boundary (owner stack role +
* the `enableOwnerInstanceAdmin` flag) and floored by code on
* platform-owned surfaces. Surfaces that assume a self-hosted operator
* will claim the instance (e.g. the first-admin bootstrap gate) should
* treat a cloud-managed instance as already set up.
*/
export function isCloudManagedInstance(): boolean {
return Boolean(process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim());
}
/**
* Whether the trusted-header actor being resolved should carry computed
* instance-admin elevation: only the stack `owner` role elevates, and only
* while `enableOwnerInstanceAdmin` is enabled. The flag is resolved through
* the instance-settings service so the cloud managed-config overlay applies
* (the harness can turn elevation off fleet-wide without touching tenant
* DBs). Fails closed: a settings read error means no elevation.
*/
async function resolveOwnerInstanceAdmin(
db: Db,
stackRole: "owner" | "admin" | "member" | "support",
): Promise<boolean> {
if (stackRole !== "owner") return false;
try {
const experimental = await instanceSettingsService(db).getExperimental();
return experimental.enableOwnerInstanceAdmin === true;
} catch (err) {
logger.warn(
{ err },
"Failed to resolve enableOwnerInstanceAdmin for cloud tenant owner; treating elevation as disabled",
);
return false;
}
}
export async function resolveCloudTenantActor(db: Db, req: Request): Promise<Express.Request["actor"] | null> {
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!expectedToken) return null;
@ -500,7 +529,12 @@ export async function resolveCloudTenantActor(db: Db, req: Request): Promise<Exp
membershipRole: membership.membershipRole,
status: membership.status,
}],
isInstanceAdmin: false,
// Computed per request, never persisted: the stack owner is elevated to
// instance admin of their own dedicated instance only while the
// `enableOwnerInstanceAdmin` flag is on. Non-owner stack roles stay
// company-scoped. Turning the flag off de-elevates on the next request —
// there is no role row to clean up.
isInstanceAdmin: await resolveOwnerInstanceAdmin(db, stackRole),
source: "cloud_tenant",
};
}

View File

@ -1,14 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Request } from "express";
import type { Db } from "@paperclipai/db";
import { authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import { authUsers, companies, companyMemberships, instanceSettings, instanceUserRoles } from "@paperclipai/db";
import { resolveCloudTenantActor } from "./auth.js";
// Minimal fake Drizzle Db: records every table passed to .insert() / .delete() and
// supports the chained call shapes used by resolveCloudTenantActor (values /
// onConflictDo* / returning().then() / delete().where()). The chain is awaitable so
// onConflictDo* / returning().then() / delete().where()), plus the
// select().from(instanceSettings).where().then() read the owner-elevation flag
// resolution performs through instanceSettingsService. The chain is awaitable so
// directly-awaited statements resolve.
function createFakeDb(membershipRow = { companyId: "company-x", membershipRole: "owner", status: "active" }) {
function createFakeDb(options: {
membershipRow?: { companyId: string; membershipRole: string; status: string };
settingsRow?: Record<string, unknown> | null;
selectThrows?: boolean;
} = {}) {
const membershipRow =
options.membershipRow ?? { companyId: "company-x", membershipRole: "owner", status: "active" };
const settingsRow =
options.settingsRow === undefined
? {
id: "00000000-0000-0000-0000-000000000001",
singletonKey: "default",
defaultEnvironmentId: null,
general: {},
experimental: {},
createdAt: new Date(),
updatedAt: new Date(),
}
: options.settingsRow;
const insertedTables: unknown[] = [];
const deletedTables: unknown[] = [];
const chain: Record<string, unknown> = {};
@ -27,10 +47,33 @@ function createFakeDb(membershipRow = { companyId: "company-x", membershipRole:
deletedTables.push(table);
return chain;
},
select: () => {
if (options.selectThrows) throw new Error("select unavailable");
return {
from: (table: unknown) => ({
where: () => ({
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(table === instanceSettings && settingsRow ? [settingsRow] : []).then(resolve),
}),
}),
};
},
} as unknown as Db;
return { db, insertedTables, deletedTables };
}
function settingsRowWith(experimental: Record<string, unknown>) {
return {
id: "00000000-0000-0000-0000-000000000001",
singletonKey: "default",
defaultEnvironmentId: null,
general: {},
experimental,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function fakeReq(headers: Record<string, string>): Request {
const lower: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
@ -45,15 +88,32 @@ const VALID_HEADERS = {
"x-paperclip-cloud-stack-role": "owner",
};
const MANAGED_CONFIG_FLAG_ON = JSON.stringify({
v: 1,
mode: "cloud",
catalogVersion: "test",
features: { enableOwnerInstanceAdmin: true },
plugins: { autoInstall: [] },
});
const MANAGED_CONFIG_FLAG_OFF = JSON.stringify({
v: 1,
mode: "cloud",
catalogVersion: "test",
features: { enableOwnerInstanceAdmin: false },
plugins: { autoInstall: [] },
});
describe("resolveCloudTenantActor (shared-pool hardening)", () => {
beforeEach(() => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
delete process.env.PAPERCLIP_MANAGED_CONFIG;
});
it("never grants instance admin", async () => {
it("does not grant instance admin by default (flag off)", async () => {
const { db, insertedTables } = createFakeDb();
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor).not.toBeNull();
@ -94,7 +154,9 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => {
});
it("maps a non-owner stack role through to the membership without elevating", async () => {
const { db } = createFakeDb({ companyId: "company-y", membershipRole: "member", status: "active" });
const { db } = createFakeDb({
membershipRow: { companyId: "company-y", membershipRole: "member", status: "active" },
});
const actor = await resolveCloudTenantActor(
db,
fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" }),
@ -102,4 +164,69 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => {
expect(actor!.isInstanceAdmin).toBe(false);
expect(actor?.memberships?.[0]?.membershipRole).toBe("member");
});
describe("owner instance-admin elevation (enableOwnerInstanceAdmin)", () => {
it("elevates the owner while the flag is enabled, still without any role row", async () => {
const { db, insertedTables, deletedTables } = createFakeDb({
settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }),
});
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor!.isInstanceAdmin).toBe(true);
expect(actor!.source).toBe("cloud_tenant");
// Elevation is computed, never persisted: no instance_user_roles insert,
// and the stale-row purge still runs on every authentication.
expect(insertedTables).not.toContain(instanceUserRoles);
expect(deletedTables).toContain(instanceUserRoles);
});
it("does not elevate the owner while the flag is disabled", async () => {
const { db } = createFakeDb({
settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: false }),
});
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor!.isInstanceAdmin).toBe(false);
});
it.each(["member", "admin", "support"] as const)(
"never elevates the %s stack role even with the flag enabled",
async (stackRole) => {
const { db } = createFakeDb({
membershipRow: { companyId: "company-y", membershipRole: "member", status: "active" },
settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }),
});
const actor = await resolveCloudTenantActor(
db,
fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": stackRole }),
);
expect(actor).not.toBeNull();
expect(actor!.isInstanceAdmin).toBe(false);
},
);
it("resolves the flag through the managed overlay: overlay on elevates over a DB value of off", async () => {
process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_FLAG_ON;
const { db } = createFakeDb({
settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: false }),
});
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor!.isInstanceAdmin).toBe(true);
});
it("resolves the flag through the managed overlay: overlay off wins over a DB value of on", async () => {
process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_FLAG_OFF;
const { db } = createFakeDb({
settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }),
});
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor!.isInstanceAdmin).toBe(false);
});
it("fails closed when the settings read errors: actor resolves without elevation", async () => {
const { db, deletedTables } = createFakeDb({ selectThrows: true });
const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS));
expect(actor).not.toBeNull();
expect(actor!.isInstanceAdmin).toBe(false);
expect(deletedTables).toContain(instanceUserRoles);
});
});
});

View File

@ -43,11 +43,29 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../middleware/auth.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
const execFileAsync = promisify(execFile);
/**
* Floor: on cloud-managed instances adapter code is bundled into the platform
* image; fetching and loading external adapter packages at runtime stays off
* for every actor, including instance admins. Adapter code executes in the
* server process, so a runtime install would let an instance admin read the
* platform trust anchors from the process environment (mirrors the
* bundled-only plugin install floor in plugin-install-guard.ts).
*/
function assertAdapterCodeInstallAllowed() {
if (isCloudManagedInstance()) {
throw forbidden("Adapter installation is platform-managed on cloud-managed instances", {
code: "adapter_install_platform_managed",
});
}
}
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
@ -232,6 +250,7 @@ export function adapterRoutes() {
*/
router.post("/adapters/install", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
@ -569,6 +588,7 @@ export function adapterRoutes() {
// package name, but without the risk of losing the store record.
router.post("/adapters/:type/reinstall", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
const type = req.params.type;

View File

@ -15,6 +15,10 @@ import {
updateEnvironmentSchema,
} from "@paperclipai/shared";
import { conflict, forbidden, unprocessable } from "../errors.js";
import { isCloudManagedInstance } from "../middleware/auth.js";
import { getManagedInstanceConfig, SECRET_LIKE_CONFIG_KEY_PATTERN } from "../services/managed-config.js";
import { parseExecutionPolicyBootstrapEnv } from "../services/execution-policy-bootstrap.js";
import { isExecutionForcedToKubernetes } from "../services/execution-allowlist.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
@ -50,6 +54,211 @@ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Whether this environment row was provisioned by the platform: the
* managed-config environment provisioner marker, or the legacy managed
* Kubernetes wrapper marker (rows created by builds that predate the
* generalized provisioner and have not been adopted yet).
*/
export function isPlatformProvisionedEnvironment(environment: {
metadata: Record<string, unknown> | null;
}): boolean {
return (
environment.metadata?.managedByPaperclip === true ||
environment.metadata?.managedKubernetesSandbox === true
);
}
function redactSecretLikeConfigKeys(value: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
if (SECRET_LIKE_CONFIG_KEY_PATTERN.test(key)) continue;
if (isPlainRecord(child)) {
result[key] = redactSecretLikeConfigKeys(child);
} else if (Array.isArray(child)) {
result[key] = child.map((element) =>
isPlainRecord(element) ? redactSecretLikeConfigKeys(element) : element,
);
} else {
result[key] = child;
}
}
return result;
}
/**
* Floor view of a platform-provisioned environment on a cloud-managed
* instance: env vars and credential-shaped config keys are never echoed to
* ANY actor, including instance admins while structural config (provider,
* image, template, region, ...) and the managed markers in `metadata` stay
* visible so admin surfaces can render the environment and show its
* platform-managed state.
*/
export function applyPlatformProvisionedEnvironmentFloor<T extends {
config: Record<string, unknown> | null;
envVars?: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}>(environment: T): T {
return {
...environment,
config: redactSecretLikeConfigKeys(isPlainRecord(environment.config) ? environment.config : {}),
...(Object.prototype.hasOwnProperty.call(environment, "envVars") ? { envVars: {} } : {}),
};
}
const PLATFORM_PROVISIONED_MARKER_KEYS = [
"managedByPaperclip",
"managedKubernetesSandbox",
] as const;
/**
* Whether some bootstrap path on this instance currently owns the managed
* sandbox slot: the managed-config `environments` section
* (`applyManagedEnvironments`) or the forced execution-mode bootstrap
* (`PAPERCLIP_EXECUTION_MODE=kubernetes`). Both adopt and refresh the
* marked sandbox row on every boot. Fails closed: an unparseable document
* or env value counts as configured, keeping the slot protected (a
* malformed value refuses startup anyway, so a running server never hits
* the catch).
*/
function isManagedSandboxProvisioningConfigured(): boolean {
try {
if ((getManagedInstanceConfig()?.environments.length ?? 0) > 0) return true;
} catch {
return true;
}
try {
return parseExecutionPolicyBootstrapEnv(process.env) !== null;
} catch {
return true;
}
}
/**
* Whether this row occupies a provisioner-owned environment slot whose
* platform markers are LIVE state i.e. some platform code path converges
* the markers on this exact row, so a marker here is never a stale
* leftover:
*
* - The single local row (`environments_local_driver_idx`): on a
* cloud-managed instance `ensureLocalEnvironment` adopts and stamps it
* from every caller (company creation, the heartbeat, run
* orchestration), so the local slot is platform-owned unconditionally.
* - The single marked sandbox row (`environments_managed_sandbox_idx`):
* owned only while a managed-sandbox bootstrap path is configured
* `ensureManagedSandboxEnvironment` then adopts and refreshes whichever
* row holds the slot on every boot.
* - Any sandbox row bearing the legacy kubernetes marker while the
* persisted instance execution policy forces kubernetes:
* `findKubernetesEnvironment` selects marked rows (newest first) for
* every forced run, so under that policy each marked row is or on the
* newest row's removal becomes the live runtime row. This holds even
* with no bootstrap path configured: the per-run guard reads the
* persisted `executionMode`, which can outlive the env that seeded it.
*
* With no provisioning path configured and no forced-kubernetes policy the
* platform holds no claim on any sandbox row, so a platform marker there
* is stale by definition and stays recoverable via the marker-clear
* escape hatch below.
*/
async function isPlatformSlotEnvironment(
environment: { driver: string; metadata: Record<string, unknown> | null },
options: { isForcedKubernetesExecution: () => Promise<boolean> },
): Promise<boolean> {
if (environment.driver === "local") return true;
if (environment.driver !== "sandbox") return false;
if (
environment.metadata?.managedByPaperclip === true &&
isManagedSandboxProvisioningConfigured()
) {
return true;
}
return (
environment.metadata?.managedKubernetesSandbox === true &&
(await options.isForcedKubernetesExecution())
);
}
/**
* Returns true when the PATCH body is a metadata-only write whose only
* purpose is to clear platform markers (setting them to null/false). This is
* the escape hatch for rows that had these markers stamped through the old
* unrestricted API before the floor was introduced. It never applies to a
* row whose slot markers are live (see `isPlatformSlotEnvironment`)
* clearing a live slot row's markers would reclassify it as
* tenant-managed and lift the write floor in two steps.
*/
function isPlatformMarkerClearOnlyPatch(body: unknown): boolean {
if (!isPlainRecord(body)) return false;
const bodyKeys = Object.keys(body);
if (bodyKeys.length !== 1 || bodyKeys[0] !== "metadata") return false;
const metadata = body.metadata;
if (!isPlainRecord(metadata)) return false;
const metaKeys = Object.keys(metadata);
if (metaKeys.length === 0) return false;
return metaKeys.every(
(key) =>
(PLATFORM_PROVISIONED_MARKER_KEYS as readonly string[]).includes(key) &&
(metadata[key] === null || metadata[key] === false),
);
}
/**
* Floor: on cloud-managed instances, platform-provisioned rows are
* platform-owned runtime state no actor, including instance admins, may
* update or delete them. Binds to the persisted row's markers, so a patch
* cannot strip the marker to lift the floor.
*
* Exception: a metadata-only patch that only clears the marker keys is
* allowed so tenants can recover rows stamped with stale markers by the old
* unrestricted API for every row except those whose markers are live
* platform state (see `isPlatformSlotEnvironment`): there, clearing the
* markers would let the very next write reclassify the row as
* tenant-managed and bypass this floor. Every marker outside a live slot
* is stale by construction, so no row is ever locked unrecoverably.
*/
async function assertPlatformProvisionedEnvironmentWritable(
environment: { driver: string; metadata: Record<string, unknown> | null },
options?: {
patchBody: unknown;
isForcedKubernetesExecution: () => Promise<boolean>;
},
): Promise<void> {
if (!isCloudManagedInstance() || !isPlatformProvisionedEnvironment(environment)) return;
if (
options !== undefined &&
isPlatformMarkerClearOnlyPatch(options.patchBody) &&
!(await isPlatformSlotEnvironment(environment, options))
) return;
throw forbidden("Platform-provisioned environments are platform-managed on cloud-managed instances", {
code: "environment_platform_managed",
});
}
/**
* Floor: on cloud-managed instances the platform markers in `metadata` are
* reserved to the platform provisioner (which writes them at the service
* layer, not through these routes). A client payload that sets them to a
* truthy value is rejected otherwise a tenant could stamp its own row as
* platform-provisioned and permanently lock it behind the write floor above.
* Clearing (null/false) is allowed so stale markers can be removed.
*/
function assertNoClientPlatformProvisionedMarkers(metadata: unknown): void {
if (!isCloudManagedInstance() || !isPlainRecord(metadata)) return;
for (const key of PLATFORM_PROVISIONED_MARKER_KEYS) {
if (metadata[key] !== undefined && metadata[key] !== null && metadata[key] !== false) {
throw unprocessable(
`metadata.${key} is reserved to the platform on cloud-managed instances`,
{ code: "environment_platform_marker_reserved" },
);
}
}
}
export function environmentRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
@ -118,6 +327,15 @@ export function environmentRoutes(
envVars?: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}>(req: Request, environment: T): T {
// Floor: on cloud-managed instances, platform-provisioned rows use one
// view for every reader — instance admins (including computed
// owner-admins) never see env vars or credential-shaped config keys, and
// restricted readers gain the structural fields the redacted view used to
// blank (the platform config carries no secrets by the managed-config
// contract).
if (isCloudManagedInstance() && isPlatformProvisionedEnvironment(environment)) {
return applyPlatformProvisionedEnvironmentFloor(environment);
}
return canReadFullInstanceEnvironment(req)
? environment
: redactEnvironmentForRestrictedView(environment);
@ -633,6 +851,7 @@ export function environmentRoutes(
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCanAccessInstanceEnvironments(req);
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
if (req.body.driver === "local") {
const existingLocal = await svc.list({ driver: "local" });
if (existingLocal.length > 0) {
@ -682,7 +901,7 @@ export function environmentRoutes(
status: environment.status,
},
});
res.status(201).json(environment);
res.status(201).json(presentEnvironmentForRead(req, environment));
});
router.get("/environments/:id", async (req, res) => {
@ -725,6 +944,14 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
await assertPlatformProvisionedEnvironmentWritable(existing, {
patchBody: req.body,
isForcedKubernetesExecution: async () =>
isExecutionForcedToKubernetes({
executionMode: (await instanceSettings.getGeneral()).executionMode,
}),
});
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
const actor = getActorInfo(req);
const nextDriver = req.body.driver ?? existing.driver;
const nextName = req.body.name ?? existing.name;
@ -809,9 +1036,10 @@ export function environmentRoutes(
entityId: environment.id,
details: summarizeEnvironmentUpdate(patch as Record<string, unknown>, environment),
});
const presented = presentEnvironmentForRead(req, environment);
res.json(customImageReconciliation.action === "none"
? environment
: { ...environment, customImageReconciliation });
? presented
: { ...presented, customImageReconciliation });
});
router.delete("/environments/:id", async (req, res) => {
@ -821,6 +1049,7 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
await assertPlatformProvisionedEnvironmentWritable(existing);
const actor = getActorInfo(req);
const impact = await svc.getDeleteBlastRadius(existing.id);
if (!impact) {
@ -873,7 +1102,7 @@ export function environmentRoutes(
status: removed.status,
},
});
res.json(removed);
res.json(presentEnvironmentForRead(req, removed));
});
router.post("/environments/:id/probe", async (req, res) => {

View File

@ -1,5 +1,7 @@
import { Router } from "express";
import type { BackupRetentionPolicy, RunDatabaseBackupResult } from "@paperclipai/db";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../middleware/auth.js";
import { assertInstanceAdmin } from "./authz.js";
export type InstanceDatabaseBackupTrigger = "manual" | "scheduled";
@ -22,6 +24,15 @@ export function instanceDatabaseBackupRoutes(service: InstanceDatabaseBackupServ
router.post("/instance/database-backups", async (req, res) => {
assertInstanceAdmin(req);
// Floor: on cloud-managed instances database backups are platform-owned.
// The manual trigger stays off for every actor, including computed
// owner-admins — the result would also echo the server-side backup
// directory path, which managed tenants must not see.
if (isCloudManagedInstance()) {
throw forbidden("Database backups are platform-managed on cloud-managed instances", {
code: "database_backups_platform_managed",
});
}
const result = await service.runManualBackup();
res.status(201).json(result);
});

View File

@ -7,6 +7,7 @@ import {
patchInstanceGeneralSettingsSchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../middleware/auth.js";
import { validate } from "../middleware/validate.js";
import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js";
import { environmentService } from "../services/environments.js";
@ -84,6 +85,24 @@ export function instanceSettingsRoutes(db: Db) {
validate(patchInstanceGeneralSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
// Floor: on cloud-managed instances the execution mode is pinned by the
// platform (the execution-policy bootstrap writes it at boot). No
// instance admin — including a computed owner-admin — may change it: a
// forced provider switch would strand runs on a provider the platform
// never provisioned. Same-value writes pass so settings forms that echo
// the full general-settings object keep working. Absent and "any" both
// mean unrestricted, so they compare equal.
if (
isCloudManagedInstance() &&
Object.prototype.hasOwnProperty.call(req.body, "executionMode")
) {
const current = await svc.getGeneral();
if ((req.body.executionMode ?? "any") !== (current.executionMode ?? "any")) {
throw forbidden("executionMode is platform-managed on cloud-managed instances", {
code: "execution_mode_platform_managed",
});
}
}
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();

View File

@ -1542,13 +1542,16 @@ export function authorizationService(db: Db) {
explanation: "Allowed because the actor is the local implicit board.",
});
}
// cloud_tenant actors are company-scoped by contract and must never be
// elevated — not even via stale instance_admin rows left behind by
// deployments that ran the pre-hardening cloud_tenant path.
// A cloud_tenant actor's computed `isInstanceAdmin` flag is trusted: it
// can only be set by the attested trusted-header resolver (stack owner +
// `enableOwnerInstanceAdmin`). The `instance_user_roles` DB lookup stays
// excluded for cloud_tenant actors, so a stale or hand-inserted
// instance_admin row left behind by deployments that ran the
// pre-hardening cloud_tenant path still elevates nothing.
if (
!input.actor.ignoreInstanceAdmin &&
input.actor.source !== "cloud_tenant" &&
(input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId))
(input.actor.isInstanceAdmin ||
(input.actor.source !== "cloud_tenant" && await isInstanceAdmin(input.actor.userId)))
) {
return allow({
action: input.action,

View File

@ -28,6 +28,7 @@ import {
type UpdateEnvironment,
} from "@paperclipai/shared";
import { conflict } from "../errors.js";
import { isCloudManagedInstance } from "../middleware/auth.js";
type EnvironmentRow = typeof environments.$inferSelect;
type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect;
@ -403,6 +404,21 @@ export function environmentService(db: Db) {
return row ? toEnvironmentLease(row) : null;
},
/**
* Idempotently ensure THE local-driver environment row; the partial
* unique index `environments_local_driver_idx` enforces at most one per
* instance.
*
* On a cloud-managed instance an existing row is additionally ADOPTED
* stamped `managedByPaperclip: true` (other metadata preserved) so the
* single local slot is platform-owned there by construction, mirroring
* `ensureManagedSandboxEnvironment`'s adoption of the sandbox slot. This
* is what lets the environment-routes write floor treat a local row's
* platform markers as live state rather than a stale leftover: every
* caller (company creation, the heartbeat, run orchestration) converges
* the marker. Self-hosted instances keep the historical behavior:
* an existing row is returned untouched.
*/
ensureLocalEnvironment: async (_companyId?: string): Promise<Environment> => {
const now = new Date();
const insert = () =>
@ -444,6 +460,19 @@ export function environmentService(db: Db) {
if (!existing) {
throw new Error("Failed to ensure local environment");
}
const existingMetadata = (existing.metadata ?? {}) as Record<string, unknown>;
if (isCloudManagedInstance() && existingMetadata.managedByPaperclip !== true) {
const adopted = await db
.update(environments)
.set({
metadata: { ...existingMetadata, managedByPaperclip: true },
updatedAt: new Date(),
})
.where(eq(environments.id, existing.id))
.returning()
.then((rows) => rows[0] ?? existing);
return toEnvironment(adopted);
}
return toEnvironment(existing);
},

View File

@ -231,6 +231,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
worktreeRunExecutionActivationInstanceId:
@ -265,6 +266,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableIssueGraphLivenessAutoRecovery: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,

View File

@ -76,9 +76,11 @@ export interface ManagedEnvironmentSpec {
* reach a managed instance as process environment variables (every bundled
* sandbox provider falls back to its env var when `config` omits the key,
* e.g. `DAYTONA_API_KEY`), so any secret-looking config key in the document
* is a misrouted credential and fails startup.
* is a misrouted credential and fails startup. The environments API reuses
* this pattern to floor credential-shaped config keys out of
* platform-provisioned environment responses on managed instances.
*/
const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i;
export const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i;
function findSecretLikeConfigKey(value: Record<string, unknown>, path: string): string | null {
for (const [key, child] of Object.entries(value)) {

View File

@ -94,6 +94,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
issueGraphLivenessAutoRecoveryLookbackHours: 24,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,