mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## Thinking Path > - Paperclip connects agents to external tools through saved accounts and per-action permissions. > - Notebook exposes artifact management through an ordinary remote MCP server with OAuth. > - A private pilot needs a hidden, default-off catalog and execution gate. > - Local file publishing belongs to Notebook, which can accept bounded file bytes through ordinary MCP tools. > - Paperclip therefore needs the connector registration and generic availability checks, with no file-transfer architecture. ## Linked Issues or Issue Description **Subsystem affected** Apps catalog, experimental feature metadata, connection setup, and connector availability. **Problem or motivation** Users need to connect Notebook as their Cloud account, assign its tools to agents, and retain Paperclip's normal Allowed / Ask first / Off rules. The private pilot must remain hidden until an operator enables it. Notebook should own its upload protocol and local publishing script. **Proposed solution** Register a personal remote MCP OAuth connection at Notebook's canonical endpoint. Reuse existing OAuth discovery, credential storage, refresh, reconnect, and removal. Enforce availability through generic catalog feature metadata and saved application identity. Keep Notebook's standalone uploader in its own service repository; it calls the ordinary gateway and receives no saved OAuth credential. **Alternatives considered** A gallery-only filter permits retained tools and deep links while disabled. A shared workspace publisher and private upload-ticket transport add runtime behavior that a normal MCP connector does not require. Notebook can receive file bytes directly through its own tools instead. **Roadmap alignment** This extends Connected Apps and supports Artifacts and Work Products. ROADMAP.md and related PRs were checked. This PR is standalone on master. The separate OAuth lifecycle and file-transfer proposals (#17085 and #17086) are closed and are not dependencies. ## What Changed - Register Notebook's personal MCP OAuth method, canonical endpoint, scopes, catalog metadata, and branding. - Declare managed `enablePaperclipNotebook` with false defaults and no user-facing settings toggle or staging opt-in. - Use generic availability metadata for gallery/setup, retained connection identity, OAuth completion, health, tool/context discovery, and dispatch. Resources/prompts recheck before credentials, including retained handles. Removal remains available while disabled. - Resolve catalog application identity from the saved application key, so editable connection labels cannot bypass the hidden gate. - Select a method's sole supported credential kind when no kind is supplied. - Keep UI changes to metadata-driven availability and existing setup/reconnect controls. - Document Notebook's service-owned upload script, inline publication, ordinary action policies, and credential behavior. - Add ordinary MCP OAuth, assignment, action-policy, disabled-state, settings, and catalog tests. No workspace contribution registry, private transfer endpoint, native publisher, Runner file-reader change, or Paperclip CLI upload command is included. The entire native-runtime and CLI trees match master. Upload code lives in `paperclip-cloud/notebook/`. ## Verification Source head: `65ef741e4f476c67425b57d2fb2d6d4c00e99b06`, directly on master `53732c811bb69655eb43d3d12f2063ae4ca5ad67`. The diff is 42 files. Shared execution changes only enforce generic catalog availability; tool access also resolves saved application identity and the sole supported credential kind. Neither module contains Notebook upload logic. - Catalog/shared/settings/UI suites: 592 tests passed across 12 files. Shared/server/UI typechecks passed using installed compilers. - Fresh real PostgreSQL generic MCP suite: 84/84 passed at final source; server typecheck and module boundaries pass. Coverage includes personal OAuth, ordinary refresh/reconnect, assignments, Allowed / Ask first / Off, hidden feature enforcement, and local credential removal. Four new resources/prompts cases failed against the prior code and pass after filtering saved application identity and rechecking before credentials. They prove missing/relabelled setup markers and stale eligibility cannot dispatch or read secrets while disabled; an ordinary provider remains available with the flag off. - The full tool-access service suite passed 463/464 cases. One unchanged Google Workspace `slides.write` case timed out at 15 seconds; it passed on one isolated retry without source or timeout changes. Its exact test block matches master. The cause is not established; final-head CI must qualify the complete suite. - Maps, module boundaries, token gates, and whitespace checks passed. Byte comparisons confirm the former transfer/runtime/CLI surfaces match master. - Fresh exact-head Greptile is 5/5 with zero unresolved findings after an exhaustive feedback audit. Current-head CI workflow [39625691137](https://github.com/paperclipai/paperclip/actions/runs/39625691137) succeeded, including both required checks (`ci / verify`, `ci / e2e`), Build, and browser E2E. Supplementary security/advisory scan status is recorded in the PR Checks panel and qualification receipt; prior-head scans are not counted. The superseded `4bd7af9b2e` completed CI successfully, but Greptile found the valid resources/prompts availability gap described above. It was fixed, replied to, and resolved; earlier-head results do not qualify this revision. Preserved environment failures: the first socket-restricted UI/route run had 93 listener setup failures, and the same 93 cases passed with local-socket access. The package-manager launcher could not fetch its signature metadata; installed compilers passed without dependency or configuration changes. The full service timeout and isolated retry remain recorded separately. The superseded `585e870e73` CI also failed an unchanged agent-chat project-plan revision assertion after task creation; the complete spec and relevant runtime sources match master, and a same-master prior run passed. Its cause is not established. Logs are preserved; no test or timeout was weakened and no old-head rerun was requested. The subsequent heads run full CI independently; no prior-head pass substitutes for current qualification. The Notebook uploader requires a separate service release. This public PR adds no uploader runtime to Paperclip and claims no newly deployed browser/agent acceptance. The Notebook-owned upload path is qualified separately. Code-owner review remains required before merge. ## Risks - The feature flag gates the curated connector; it does not create a Notebook service-side team allowlist or block arbitrary standard MCP URLs. - Publishing, sharing, and deletion retain the existing assignment and action-policy boundaries. - The hidden feature must be checked for retained tools and saved identity, including when connection labels change. - Disconnect removes local credentials. It does not revoke the remote Notebook authorization; the owner can revoke it in Notebook. - Ordinary refresh uses the existing client. A lost response after refresh-token rotation can require reconnect. - The standalone Notebook script and its bounded upload tools must ship together in the Notebook service before that documented convenience path is available. - No database schema, workflow, lockfile, shared transfer, or Runner change is included. No merge, deployment, or rollout is performed by this update. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository inspection, code execution, and tool use. The runtime did not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
632 lines
25 KiB
TypeScript
632 lines
25 KiB
TypeScript
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companies, instanceSettings } from "@paperclipai/db";
|
|
|
|
/**
|
|
* A `Db` or an open transaction handle — the subset of query builders the
|
|
* settings writes use. Lets `update` run inside a caller's transaction so
|
|
* it commits atomically with a sibling write.
|
|
*/
|
|
type InstanceSettingsTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
export type InstanceSettingsWriteDb = Pick<
|
|
Db | InstanceSettingsTransaction,
|
|
"select" | "insert" | "update"
|
|
>;
|
|
import {
|
|
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
|
DEFAULT_BACKUP_RETENTION,
|
|
PAPERCLIP_CLOUD_MANAGED_BY,
|
|
instanceGeneralSettingsSchema,
|
|
type InstanceGeneralSettings,
|
|
instanceExperimentalSettingsSchema,
|
|
type InstanceExperimentalSettings,
|
|
type InstanceExperimentalSettingsWithManaged,
|
|
type ManagedExperimentalFeatureKey,
|
|
type ManagedSettingMetadata,
|
|
type PatchInstanceGeneralSettings,
|
|
type InstanceSettings,
|
|
type PatchInstanceSettings,
|
|
type PatchInstanceExperimentalSettings,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
INSTANCE_FEATURE_CATALOG,
|
|
applyOperatorGeneralDefaults,
|
|
stripOperatorGeneralEchoes,
|
|
} from "@paperclipai/shared";
|
|
import { eq } from "drizzle-orm";
|
|
import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js";
|
|
import { getOperatorSettingDefaults } from "./setting-defaults.js";
|
|
import { isCloudStagingExperimentalSetting, isCloudStagingInstance } from "./cloud-instance.js";
|
|
import { forbidden } from "../errors.js";
|
|
|
|
const DEFAULT_SINGLETON_KEY = "default";
|
|
const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
|
|
const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
|
|
const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
|
|
interface InstanceSettingsServiceOptions {
|
|
runtimeEnv?: Record<string, string | undefined>;
|
|
now?: () => Date;
|
|
}
|
|
|
|
type WorktreeRunExecutionSuppressedReason =
|
|
| "not_worktree_runtime"
|
|
| "flag_disabled"
|
|
| "missing_cutoff"
|
|
| "missing_instance_id"
|
|
| "instance_id_mismatch"
|
|
| "settings_read_error";
|
|
|
|
export type WorktreeRunExecutionActivationState =
|
|
| {
|
|
armed: true;
|
|
cutoff: string;
|
|
activationInstanceId: string;
|
|
reason: null;
|
|
}
|
|
| {
|
|
armed: false;
|
|
cutoff: null;
|
|
activationInstanceId: string | null;
|
|
reason: WorktreeRunExecutionSuppressedReason;
|
|
};
|
|
|
|
export function isTruthyRuntimeEnvValue(value: string | undefined) {
|
|
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
|
|
}
|
|
|
|
function getRuntimeInstanceId(env: Record<string, string | undefined>) {
|
|
const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim();
|
|
return instanceId ? instanceId : null;
|
|
}
|
|
|
|
function stripServerManagedExperimentalPatchFields(
|
|
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
|
): PatchInstanceExperimentalSettings {
|
|
const {
|
|
worktreeRunExecutionActivatedAt: _ignoredActivatedAt,
|
|
worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId,
|
|
...patchable
|
|
} = patch as Record<string, unknown>;
|
|
return patchable as PatchInstanceExperimentalSettings;
|
|
}
|
|
|
|
export function applyExperimentalSettingsPatch(
|
|
current: unknown,
|
|
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
|
options: InstanceSettingsServiceOptions = {},
|
|
): InstanceExperimentalSettings {
|
|
const previousExperimental = normalizeExperimentalSettings(current);
|
|
const patchable = stripServerManagedExperimentalPatchFields(patch);
|
|
const nextExperimental = normalizeExperimentalSettings({
|
|
...previousExperimental,
|
|
...patchable,
|
|
});
|
|
const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call(
|
|
patchable,
|
|
"enableWorktreeRunExecution",
|
|
);
|
|
|
|
if (!hasWorktreeRunExecutionPatch) {
|
|
return nextExperimental;
|
|
}
|
|
|
|
if (nextExperimental.enableWorktreeRunExecution !== true) {
|
|
return {
|
|
...nextExperimental,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
};
|
|
}
|
|
|
|
if (previousExperimental.enableWorktreeRunExecution === true) {
|
|
return nextExperimental;
|
|
}
|
|
|
|
const runtimeEnv = options.runtimeEnv ?? process.env;
|
|
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
|
return nextExperimental;
|
|
}
|
|
|
|
return {
|
|
...nextExperimental,
|
|
worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv),
|
|
};
|
|
}
|
|
|
|
function suppressWorktreeRunExecution(
|
|
reason: WorktreeRunExecutionSuppressedReason,
|
|
activationInstanceId: string | null = null,
|
|
): WorktreeRunExecutionActivationState {
|
|
return {
|
|
armed: false,
|
|
cutoff: null,
|
|
activationInstanceId,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
export function resolveWorktreeRunExecutionActivation(
|
|
experimental: InstanceExperimentalSettings,
|
|
currentInstanceId: string | null | undefined,
|
|
): WorktreeRunExecutionActivationState {
|
|
if (experimental.enableWorktreeRunExecution !== true) {
|
|
return suppressWorktreeRunExecution(
|
|
"flag_disabled",
|
|
experimental.worktreeRunExecutionActivationInstanceId,
|
|
);
|
|
}
|
|
if (!experimental.worktreeRunExecutionActivatedAt) {
|
|
return suppressWorktreeRunExecution(
|
|
"missing_cutoff",
|
|
experimental.worktreeRunExecutionActivationInstanceId,
|
|
);
|
|
}
|
|
if (!currentInstanceId) {
|
|
return suppressWorktreeRunExecution(
|
|
"missing_instance_id",
|
|
experimental.worktreeRunExecutionActivationInstanceId,
|
|
);
|
|
}
|
|
if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
|
|
return suppressWorktreeRunExecution(
|
|
"instance_id_mismatch",
|
|
experimental.worktreeRunExecutionActivationInstanceId,
|
|
);
|
|
}
|
|
return {
|
|
armed: true,
|
|
cutoff: experimental.worktreeRunExecutionActivatedAt,
|
|
activationInstanceId: currentInstanceId,
|
|
reason: null,
|
|
};
|
|
}
|
|
|
|
export async function resolveWorktreeRunExecutionActivationState(options: {
|
|
getExperimental: () => Promise<InstanceExperimentalSettings>;
|
|
runtimeEnv?: Record<string, string | undefined>;
|
|
}): Promise<WorktreeRunExecutionActivationState> {
|
|
const runtimeEnv = options.runtimeEnv ?? process.env;
|
|
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
|
return suppressWorktreeRunExecution("not_worktree_runtime");
|
|
}
|
|
try {
|
|
return resolveWorktreeRunExecutionActivation(
|
|
await options.getExperimental(),
|
|
getRuntimeInstanceId(runtimeEnv),
|
|
);
|
|
} catch {
|
|
return suppressWorktreeRunExecution("settings_read_error");
|
|
}
|
|
}
|
|
|
|
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
|
const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
|
|
if (parsed.success) {
|
|
return {
|
|
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
|
|
feedbackDataSharingPreference:
|
|
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
|
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
|
|
// Absent => unrestricted; only carry through an explicit policy.
|
|
...(parsed.data.executionMode ? { executionMode: parsed.data.executionMode } : {}),
|
|
};
|
|
}
|
|
return {
|
|
censorUsernameInLogs: false,
|
|
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
|
backupRetention: DEFAULT_BACKUP_RETENTION,
|
|
};
|
|
}
|
|
|
|
export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
|
|
const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
|
|
if (parsed.success) {
|
|
return {
|
|
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
|
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
|
|
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
|
|
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
|
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
|
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
|
|
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
|
enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true,
|
|
// Apps graduated from Experimental. Ignore historical off values while
|
|
// continuing to accept the compatibility key in stored settings.
|
|
enableApps: true,
|
|
enableMcpAggregators: true,
|
|
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
|
|
enableOpenAiDot: parsed.data.enableOpenAiDot ?? false,
|
|
enableMuse: parsed.data.enableMuse ?? false,
|
|
enableGrokBot: parsed.data.enableGrokBot ?? false,
|
|
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
|
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
|
|
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
|
|
enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false,
|
|
enableCases: parsed.data.enableCases ?? false,
|
|
enableAgentChat: parsed.data.enableAgentChat ?? false,
|
|
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
|
|
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
|
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
|
|
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
|
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
|
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
|
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
|
|
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
|
enableBetaSkills: parsed.data.enableBetaSkills ?? false,
|
|
enableSummaries: parsed.data.enableSummaries ?? false,
|
|
enableStatusCards: parsed.data.enableStatusCards ?? false,
|
|
enableDecisions: parsed.data.enableDecisions ?? false,
|
|
enableFastResponses: parsed.data.enableFastResponses ?? false,
|
|
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
|
|
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
|
|
enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false,
|
|
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
|
|
enableFirstTaskPlanProposal: parsed.data.enableFirstTaskPlanProposal ?? false,
|
|
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
|
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
|
|
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
|
|
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
|
|
enableSandboxDuplexBridge: parsed.data.enableSandboxDuplexBridge ?? false,
|
|
enableRunnerPreviewIngress: parsed.data.enableRunnerPreviewIngress ?? false,
|
|
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
|
|
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
|
|
worktreeRunExecutionActivationInstanceId:
|
|
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
|
|
};
|
|
}
|
|
return {
|
|
enableEnvironments: false,
|
|
enableNativeRunner: true,
|
|
enableAiConnectionRouters: false,
|
|
enableManagedSandboxOnly: false,
|
|
enableIsolatedWorkspaces: false,
|
|
enableIsolatedWorkspacesByDefault: false,
|
|
enableStreamlinedLeftNavigation: true,
|
|
enableStreamlinedUi: true,
|
|
enableApps: true,
|
|
enableMcpAggregators: true,
|
|
enablePublicMcp: false,
|
|
enableOpenAiDot: false,
|
|
enableMuse: false,
|
|
enableGrokBot: false,
|
|
enableChatConnectors: false,
|
|
enableGitHubReviewBots: false,
|
|
enableMemoryConnectors: false,
|
|
enablePaperclipNotebook: false,
|
|
enableCases: false,
|
|
enableAgentChat: false,
|
|
enableCombinedInboxTasks: false,
|
|
enableConferenceRoomChat: false,
|
|
enableClassicTaskInterface: false,
|
|
enableIssuePlanDecompositions: false,
|
|
enableExperimentalFileViewer: false,
|
|
enableExternalObjects: false,
|
|
enableSmokeLab: false,
|
|
enableBuiltInAgents: false,
|
|
enableBetaSkills: false,
|
|
enableSummaries: false,
|
|
enableStatusCards: false,
|
|
enableDecisions: false,
|
|
enableFastResponses: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: false,
|
|
enablePaperclipDeveloperMode: false,
|
|
enableSimplifiedEnglishInteractions: false,
|
|
enableFirstTaskPlanProposal: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableOwnerInstanceAdmin: false,
|
|
enableSandboxDuplexBridge: false,
|
|
enableRunnerPreviewIngress: false,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
};
|
|
}
|
|
|
|
export type ManagedExperimentalKeyMetadata = Partial<
|
|
Record<ManagedExperimentalFeatureKey, ManagedSettingMetadata>
|
|
>;
|
|
|
|
/**
|
|
* Overlay the cloud managed-config feature values over normalized settings.
|
|
*
|
|
* Read-time precedence: code floor (cloud) > managed overlay > tenant DB
|
|
* value > schema default. (No code floors are expressed as flags today —
|
|
* floors are enforced in code at the guarded routes, independent of any
|
|
* flag value.) The overlay is deliberately never persisted: it re-asserts on
|
|
* every read, so a DB restore or manual row edit cannot resurrect a
|
|
* capability the harness has disabled.
|
|
*/
|
|
export function applyManagedExperimentalOverlay(
|
|
experimental: InstanceExperimentalSettings,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
): { experimental: InstanceExperimentalSettings; managedKeys: ManagedExperimentalKeyMetadata } {
|
|
if (!managedConfig) return { experimental, managedKeys: {} };
|
|
const next: InstanceExperimentalSettings = { ...experimental };
|
|
const managedKeys: ManagedExperimentalKeyMetadata = {};
|
|
for (const [key, value] of Object.entries(managedConfig.features) as Array<
|
|
[ManagedExperimentalFeatureKey, boolean]
|
|
>) {
|
|
// Existing Cloud stack configs may still carry retired flags. Accept the
|
|
// document during rollout, but never let retired flags disable Apps or MCP aggregators.
|
|
if (key === "enableApps" || key === "enableMcpAggregators") continue;
|
|
next[key] = value;
|
|
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
|
|
}
|
|
return { experimental: next, managedKeys };
|
|
}
|
|
|
|
/**
|
|
* Keep self-hosted-only defaults out of Cloud.
|
|
*
|
|
* The experimental schema carries one default per flag, and the feature
|
|
* catalog pins it to `selfHostedDefault`. A flag that is on by default for
|
|
* self-hosted but off by default for Cloud (`selfHostedDefault: true`,
|
|
* `cloudDefault: false`) would therefore normalize to "on" for a managed
|
|
* instance whose tenant row and managed overlay both leave it unset. Re-assert
|
|
* the declared Cloud default for exactly those flags. An explicit tenant value
|
|
* or a managed feature value still wins (the overlay is applied afterwards).
|
|
*/
|
|
export function applyCloudCatalogDefaults(
|
|
experimental: InstanceExperimentalSettings,
|
|
rawStored: unknown,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
): InstanceExperimentalSettings {
|
|
if (!managedConfig) return experimental;
|
|
const stored =
|
|
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
|
? (rawStored as Record<string, unknown>)
|
|
: {};
|
|
const next: InstanceExperimentalSettings = { ...experimental };
|
|
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
|
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
|
if (typeof stored[key] === "boolean") continue;
|
|
if (typeof managedConfig.features[key as ManagedExperimentalFeatureKey] === "boolean") continue;
|
|
(next as unknown as Record<string, unknown>)[key] = false;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Keep the write path from freezing a self-hosted default into a Cloud row.
|
|
*
|
|
* `updateExperimental` persists the whole normalized object, and the schema
|
|
* normalizes an omitted flag to its self-hosted default. Without this step an
|
|
* unrelated experimental write (say, turning on Cases) would store
|
|
* `enableNativeRunner: true` on a managed instance whose tenant row had never
|
|
* mentioned the flag; every later read would then treat the stored boolean as
|
|
* an explicit tenant choice and stop re-asserting the Cloud default.
|
|
*
|
|
* For each guarded flag (see `applyCloudCatalogDefaults`), the stored key is
|
|
* left absent unless the tenant already stored a boolean or this patch sets
|
|
* the flag to something other than the Cloud default. A patch value equal to
|
|
* the Cloud default is a full-GET echo of the read-time overlay, not a
|
|
* choice, and is stripped the same way `stripOperatorGeneralEchoes` treats
|
|
* operator defaults. Self-hosted rows are returned untouched.
|
|
*/
|
|
export function stripCloudCatalogDefaultEchoes(
|
|
rawStored: unknown,
|
|
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
|
next: InstanceExperimentalSettings,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
): Partial<InstanceExperimentalSettings> {
|
|
if (!managedConfig) return next;
|
|
const stored =
|
|
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
|
? (rawStored as Record<string, unknown>)
|
|
: {};
|
|
const patchRecord = patch as Record<string, unknown>;
|
|
const result: Record<string, unknown> = { ...next };
|
|
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
|
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
|
if (typeof stored[key] === "boolean") continue;
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(patchRecord, key) &&
|
|
typeof patchRecord[key] === "boolean" &&
|
|
patchRecord[key] !== entry.cloudDefault
|
|
) {
|
|
continue;
|
|
}
|
|
delete result[key];
|
|
}
|
|
return result as Partial<InstanceExperimentalSettings>;
|
|
}
|
|
|
|
export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) {
|
|
// Fail closed: a malformed PAPERCLIP_MANAGED_CONFIG throws here (and at
|
|
// boot in index.ts) rather than silently running without the overlay.
|
|
const managedConfig = getManagedInstanceConfig(options.runtimeEnv ?? process.env);
|
|
const staging = isCloudStagingInstance(options.runtimeEnv ?? process.env);
|
|
const enforcedManagedConfig = staging && managedConfig
|
|
? {
|
|
...managedConfig,
|
|
features: Object.fromEntries(Object.entries(managedConfig.features)
|
|
.filter(([key, enabled]) => enabled || !isCloudStagingExperimentalSetting(key))),
|
|
}
|
|
: managedConfig;
|
|
// Same posture for PAPERCLIP_SETTING_DEFAULTS: parsed once, applied per
|
|
// read, never persisted (see applyOperatorGeneralDefaults) — including on
|
|
// the write path, where a full-GET echo of the overlaid value is stripped
|
|
// back to the schema default (see stripOperatorGeneralEchoes).
|
|
const operatorDefaults = getOperatorSettingDefaults(options.runtimeEnv ?? process.env);
|
|
|
|
function toGeneralView(raw: unknown): InstanceGeneralSettings {
|
|
return applyOperatorGeneralDefaults(normalizeGeneralSettings(raw), operatorDefaults);
|
|
}
|
|
|
|
function toExperimentalView(raw: unknown): InstanceExperimentalSettingsWithManaged {
|
|
const stored = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
const defaults = staging ? { ...managedConfig?.features, ...stored } : raw;
|
|
const { experimental, managedKeys } = applyManagedExperimentalOverlay(
|
|
applyCloudCatalogDefaults(normalizeExperimentalSettings(defaults), defaults, managedConfig),
|
|
enforcedManagedConfig,
|
|
);
|
|
// Self-hosted responses stay byte-identical: no managedKeys field at all.
|
|
return managedConfig ? { ...experimental, managedKeys } : experimental;
|
|
}
|
|
|
|
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
|
return {
|
|
id: row.id,
|
|
defaultEnvironmentId: row.defaultEnvironmentId ?? null,
|
|
general: toGeneralView(row.general),
|
|
experimental: toExperimentalView(row.experimental),
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
} as InstanceSettings;
|
|
}
|
|
async function getOrCreateRow(runner: InstanceSettingsWriteDb = db) {
|
|
const existing = await runner
|
|
.select()
|
|
.from(instanceSettings)
|
|
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existing) return existing;
|
|
|
|
const now = new Date();
|
|
const [created] = await runner
|
|
.insert(instanceSettings)
|
|
.values({
|
|
singletonKey: DEFAULT_SINGLETON_KEY,
|
|
general: {},
|
|
experimental: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [instanceSettings.singletonKey],
|
|
set: {
|
|
updatedAt: now,
|
|
},
|
|
})
|
|
.returning();
|
|
|
|
if (created) return created;
|
|
|
|
const raced = await runner
|
|
.select()
|
|
.from(instanceSettings)
|
|
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (raced) return raced;
|
|
|
|
throw new Error("Failed to initialize instance settings row");
|
|
}
|
|
|
|
return {
|
|
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
|
|
|
update: async (
|
|
patch: PatchInstanceSettings,
|
|
writeOptions?: { db?: InstanceSettingsWriteDb },
|
|
): Promise<InstanceSettings> => {
|
|
// The write may run inside a caller-supplied transaction so it commits
|
|
// atomically with a sibling write (e.g. clearing the managed-default
|
|
// stamp on the environment row alongside a defaultEnvironmentId
|
|
// change). Reads use the same runner so the row is visible to the tx.
|
|
const runner = writeOptions?.db ?? db;
|
|
const current = await getOrCreateRow(runner);
|
|
const now = new Date();
|
|
const [updated] = await runner
|
|
.update(instanceSettings)
|
|
.set({
|
|
...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
|
|
? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null }
|
|
: {}),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(instanceSettings.id, current.id))
|
|
.returning();
|
|
return toInstanceSettings(updated ?? current);
|
|
},
|
|
|
|
getGeneral: async (
|
|
readOptions?: { db?: InstanceSettingsWriteDb },
|
|
): Promise<InstanceGeneralSettings> => {
|
|
const row = await getOrCreateRow(readOptions?.db);
|
|
return toGeneralView(row.general);
|
|
},
|
|
|
|
getExperimental: async (): Promise<InstanceExperimentalSettingsWithManaged> => {
|
|
const row = await getOrCreateRow();
|
|
return toExperimentalView(row.experimental);
|
|
},
|
|
|
|
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
|
|
const current = await getOrCreateRow();
|
|
const storedGeneral = normalizeGeneralSettings(current.general);
|
|
// A full-GET echo carries the overlaid operator value for a field the
|
|
// user never chose; stripping it keeps the overlay strictly read-time,
|
|
// so changing or unsetting the variable later still takes effect.
|
|
const nextGeneral = stripOperatorGeneralEchoes(
|
|
storedGeneral,
|
|
normalizeGeneralSettings({ ...storedGeneral, ...patch }),
|
|
operatorDefaults,
|
|
);
|
|
const now = new Date();
|
|
const [updated] = await db
|
|
.update(instanceSettings)
|
|
.set({
|
|
general: { ...nextGeneral },
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(instanceSettings.id, current.id))
|
|
.returning();
|
|
return toInstanceSettings(updated ?? current);
|
|
},
|
|
|
|
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
|
|
for (const [key, enabled] of Object.entries(managedConfig?.features ?? {})) {
|
|
if (enabled && patch[key as ManagedExperimentalFeatureKey] === false) {
|
|
throw forbidden(`${key} is enabled by Paperclip Cloud and cannot be disabled on this instance`, {
|
|
code: "settings_operator_managed",
|
|
});
|
|
}
|
|
}
|
|
const current = await getOrCreateRow();
|
|
// Guarded Cloud flags stay absent from the row unless chosen, so the
|
|
// read-time catalog default keeps applying (see stripCloudCatalogDefaultEchoes).
|
|
const nextExperimental = staging
|
|
? applyExperimentalSettingsPatch(toExperimentalView(current.experimental), patch, options)
|
|
: stripCloudCatalogDefaultEchoes(
|
|
current.experimental,
|
|
patch,
|
|
applyExperimentalSettingsPatch(current.experimental, patch, options),
|
|
managedConfig,
|
|
);
|
|
if (staging) {
|
|
const stored = current.experimental as Record<string, unknown> | null;
|
|
for (const key of Object.keys(INSTANCE_FEATURE_CATALOG) as ManagedExperimentalFeatureKey[]) {
|
|
if (typeof stored?.[key] !== "boolean" && !Object.prototype.hasOwnProperty.call(patch, key)) {
|
|
delete nextExperimental[key];
|
|
}
|
|
}
|
|
}
|
|
const now = new Date();
|
|
const [updated] = await db.transaction(async tx => {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
|
|
return tx
|
|
.update(instanceSettings)
|
|
.set({
|
|
experimental: { ...nextExperimental },
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(instanceSettings.id, current.id))
|
|
.returning();
|
|
});
|
|
return toInstanceSettings(updated ?? current);
|
|
},
|
|
|
|
listCompanyIds: async (): Promise<string[]> =>
|
|
db
|
|
.select({ id: companies.id })
|
|
.from(companies)
|
|
.then((rows) => rows.map((row) => row.id)),
|
|
};
|
|
}
|