From c274f10abc0e3c60a47e6ac3f786443b0e197543 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 27 Jul 2026 18:58:31 -0700 Subject: [PATCH] feat(server): computed owner instance-admin elevation for cloud-managed instances, behind platform floors (#10343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/shared/src/feature-catalog.ts | 8 + packages/shared/src/types/instance.ts | 8 + packages/shared/src/validators/instance.ts | 1 + .../__tests__/adapter-routes-authz.test.ts | 27 ++ .../__tests__/authorization-service.test.ts | 33 ++ .../src/__tests__/environment-routes.test.ts | 433 +++++++++++++++++- .../src/__tests__/environment-service.test.ts | 73 +++ .../instance-database-backups-routes.test.ts | 48 +- .../instance-settings-routes.test.ts | 97 +++- .../instance-settings-service.test.ts | 1 + server/src/middleware/auth.ts | 44 +- .../src/middleware/cloud-tenant-actor.test.ts | 137 +++++- server/src/routes/adapters.ts | 20 + server/src/routes/environments.ts | 237 +++++++++- .../src/routes/instance-database-backups.ts | 11 + server/src/routes/instance-settings.ts | 19 + server/src/services/authorization.ts | 13 +- server/src/services/environments.ts | 29 ++ server/src/services/instance-settings.ts | 2 + server/src/services/managed-config.ts | 6 +- .../InstanceExperimentalSettings.test.tsx | 1 + 21 files changed, 1224 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 04b37b751f..510679acac 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -229,6 +229,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { 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(); + }, + ); + }); }); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index f7f5375d46..1045d00aa3 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -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"); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 5b5e1672f4..c6c33353b3 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -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", diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 4b3ef48810..4ce7c8f825 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -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({ diff --git a/server/src/__tests__/instance-database-backups-routes.test.ts b/server/src/__tests__/instance-database-backups-routes.test.ts index 87774a82e4..2d06223148 100644 --- a/server/src/__tests__/instance-database-backups-routes.test.ts +++ b/server/src/__tests__/instance-database-backups-routes.test.ts @@ -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(); + }); + }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 71ea0135a1..a0adb781c6 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -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" }); + }); + }); }); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 1c47205b6f..8a38433227 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -49,6 +49,7 @@ describe("instance settings service", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, + enableOwnerInstanceAdmin: false, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 5a60ccee90..8ad775b489 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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 { + 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 { 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 | 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 = {}; @@ -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) { + return { + id: "00000000-0000-0000-0000-000000000001", + singletonKey: "default", + defaultEnvironmentId: null, + general: {}, + experimental, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + function fakeReq(headers: Record): Request { const lower: Record = {}; 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); + }); + }); }); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 07132c4f2c..fe0d6d6cbf 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -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; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 86286d4b1a..e26f687ccc 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -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 { + 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 | null; +}): boolean { + return ( + environment.metadata?.managedByPaperclip === true || + environment.metadata?.managedKubernetesSandbox === true + ); +} + +function redactSecretLikeConfigKeys(value: Record): Record { + const result: Record = {}; + 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 | null; + envVars?: Record | null; + metadata: Record | 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 | null }, + options: { isForcedKubernetesExecution: () => Promise }, +): Promise { + 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 | null }, + options?: { + patchBody: unknown; + isForcedKubernetesExecution: () => Promise; + }, +): Promise { + 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 | null; metadata: Record | 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, 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) => { diff --git a/server/src/routes/instance-database-backups.ts b/server/src/routes/instance-database-backups.ts index a7fbb5ac83..641881c5e5 100644 --- a/server/src/routes/instance-database-backups.ts +++ b/server/src/routes/instance-database-backups.ts @@ -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); }); diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 209cc477fb..405f1817c4 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -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(); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 4ca0522947..5c228fd7d8 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index d9a4466168..0689365ca1 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -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 => { 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; + 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); }, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 76e2d1c13d..0a99e87ba5 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -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, diff --git a/server/src/services/managed-config.ts b/server/src/services/managed-config.ts index b723930201..8b4ec896b3 100644 --- a/server/src/services/managed-config.ts +++ b/server/src/services/managed-config.ts @@ -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, path: string): string | null { for (const [key, child] of Object.entries(value)) { diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 0aa45bb515..31ad7b272e 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -94,6 +94,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { issueGraphLivenessAutoRecoveryLookbackHours: 24, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableOwnerInstanceAdmin: false, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null,