mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Workspace isolation keeps task checkouts separate. > - Managed hosts can enable isolation and hide its experimental toggles. > - Project, task, and routine forms still expose choices that override that policy. > - This pull request adds an operator visibility key for those controls. > - Workspace access stays available, and execution keeps its existing policy. ## Linked Issues or Issue Description **What existing behavior does this improve?** Operator control over workspace isolation settings in the UI. This follows the settings list cleanup in #13905. **Current behavior** Hiding the experimental isolation toggles leaves project policy editors, task selectors, routine and pipeline overrides, recovery actions, and workspace configuration visible. **Proposed behavior** Set `PAPERCLIP_HIDDEN_SETTINGS=workspaces.isolation` to hide these controls. Keep workspace navigation, status, files, and runtime access. Hide experimental toggles separately. Instances that do not set this key keep their controls. **Reason and benefit** Users on managed hosts should use the host's isolation default. A hidden form must not submit a stale draft that overrides it. ## What Changed - Add the UI-only `workspaces.isolation` key to the shared visibility registry. - Hide project workspace policy, task and subtask selectors, routine and pipeline overrides, and isolated re-issue actions. - Hide the workspace Configuration tab and redirect direct links to workspace issues. - Omit hidden new-task and routine overrides. Keep explicit task/subtask workspace launch context, saved policies, and automatic branch values for workspace routine runs. - Wait for the health visibility policy before showing controls. Keep workspace access and all execution APIs available. - Document the key and test visibility, form payloads, deep links, and unchanged workspace access. ## Verification - All 52 CI checks pass on `50cc770d33` (two expected skips). The branch is mergeable. Greptile is 5/5 with no unresolved comments. - `pnpm -r typecheck` passed. - `pnpm build` passed. - `pnpm check:token-gates` passed. - Targeted UI checks passed: 346 tests across 14 suites, including hidden project/task controls, stale task drafts, routine branch defaults, recovery actions, configuration deep links, and workspace access. - Shared settings-visibility tests passed: 11 tests. - `pnpm test:run` was run and stopped after reproducing five failures in unchanged server tests: two `chat-channels.integration` cases (linked-request provenance and direct external-chat finals) and three `company-skills-service` cases (runtime refresh, concurrent download, and explicit update). The earlier local run for #13905 showed the same failures. The full local suite is not claimed green. Targeted UI/shared checks pass. All PR CI shards, including the affected chat and skills suites, pass. - Reviewed the diff for secrets, private links, and run artifacts. ## Risks This key changes UI visibility only. It does not reject API calls or change feature values. Operators must enable isolation and its default through their existing policy mechanism. Older app versions ignore the new key until upgraded. Removing the key restores the controls. No schema changes. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and test execution. ## 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 linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally; targeted checks pass (full-suite limitation documented above) - [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>
192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalog.js";
|
|
|
|
/**
|
|
* Operator-configurable settings visibility.
|
|
*
|
|
* A hosting operator (a managed cloud, an internal shared server) can hide
|
|
* settings surfaces that do not apply to their deployment by setting the
|
|
* `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated
|
|
* list of keys from this registry. Hiding a surface removes it from the UI
|
|
* (nav, routes, page sections). Surfaces backed by instance-level mutation
|
|
* routes are also floored with a 403 carrying
|
|
* `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters
|
|
* pages, every field-backed General section, every experimental toggle
|
|
* (individually or via the whole Experimental page), and the company Import
|
|
* page (whose whole route surface is floored). The other company pages are
|
|
* UI-visibility keys only: their APIs (memberships, invites, secrets,
|
|
* exports) stay live for agents and integrations.
|
|
*
|
|
* Nothing is hidden by default: with the variable unset, UI and API behave
|
|
* exactly as before this mechanism existed.
|
|
*
|
|
* Unknown keys are ignored (with a server-side warning) rather than rejected,
|
|
* so an operator may roll one list across a fleet of mixed app versions: an
|
|
* image that predates a key simply keeps that surface visible instead of
|
|
* refusing to boot.
|
|
*/
|
|
|
|
/**
|
|
* Instance settings pages that can be hidden (nav entry + route). The General
|
|
* page is deliberately not hideable: it is the settings root and the redirect
|
|
* target for hidden pages. Individual General sections are hideable below.
|
|
*/
|
|
export const HIDEABLE_INSTANCE_PAGES = [
|
|
"instance.profile",
|
|
"instance.environments",
|
|
"instance.access",
|
|
"instance.experimental",
|
|
"instance.plugins",
|
|
"instance.adapters",
|
|
] as const;
|
|
|
|
export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number];
|
|
|
|
/**
|
|
* Company-level settings pages that can be hidden (nav entry + tab + route).
|
|
* The company General page is deliberately not hideable: it is the settings
|
|
* root and the redirect target for hidden pages. `company.import` also floors
|
|
* the import API routes; the rest only hide UI surfaces.
|
|
*/
|
|
export const HIDEABLE_COMPANY_PAGES = [
|
|
"company.members",
|
|
"company.invites",
|
|
"company.secrets",
|
|
"company.export",
|
|
"company.import",
|
|
] as const;
|
|
|
|
export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_PAGES)[number];
|
|
|
|
/**
|
|
* Sub-surfaces of company settings pages that can be hidden individually.
|
|
* UI-visibility keys only: the backing APIs stay live for agents and
|
|
* integrations. Hiding the whole page (`company.secrets`) already removes
|
|
* everything inside it; these keys hide one tab while the page stays up.
|
|
*/
|
|
export const HIDEABLE_COMPANY_SECTIONS = [
|
|
"company.secrets.vaults",
|
|
"company.secrets.proposals",
|
|
] as const;
|
|
|
|
export type HideableCompanySection = (typeof HIDEABLE_COMPANY_SECTIONS)[number];
|
|
|
|
/**
|
|
* Sections of Instance → General that can be hidden. Field-backed sections
|
|
* (their suffix names a general-settings field) also floor writes to that
|
|
* field; `deploymentStatus` and `signOut` are read-only UI with no field.
|
|
*/
|
|
export const HIDEABLE_GENERAL_SECTIONS = [
|
|
"instance.general.deploymentStatus",
|
|
"instance.general.censorUsernameInLogs",
|
|
"instance.general.keyboardShortcuts",
|
|
"instance.general.backupRetention",
|
|
"instance.general.feedbackDataSharingPreference",
|
|
"instance.general.signOut",
|
|
] as const;
|
|
|
|
export type HideableGeneralSection = (typeof HIDEABLE_GENERAL_SECTIONS)[number];
|
|
|
|
/** General sections that are informational UI only, with no settings field. */
|
|
export const UI_ONLY_GENERAL_SECTIONS = [
|
|
"instance.general.deploymentStatus",
|
|
"instance.general.signOut",
|
|
] as const satisfies readonly HideableGeneralSection[];
|
|
|
|
export type HideableExperimentalSetting = `instance.experimental.${InstanceFeatureKey}`;
|
|
|
|
/** The visibility key for an experimental toggle; every boolean flag is hideable. */
|
|
export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperimentalSetting {
|
|
return `instance.experimental.${key}`;
|
|
}
|
|
|
|
/** Workspace policy editors and selectors. UI-only; execution and APIs stay active. */
|
|
export const HIDEABLE_WORKSPACE_SECTIONS = ["workspaces.isolation"] as const;
|
|
export type HideableWorkspaceSection = (typeof HIDEABLE_WORKSPACE_SECTIONS)[number];
|
|
|
|
export type HideableSettingKey =
|
|
| HideableWorkspaceSection
|
|
| HideableInstancePage
|
|
| HideableCompanyPage
|
|
| HideableCompanySection
|
|
| HideableGeneralSection
|
|
| HideableExperimentalSetting;
|
|
|
|
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
|
|
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
|
|
...HIDEABLE_WORKSPACE_SECTIONS,
|
|
...HIDEABLE_INSTANCE_PAGES,
|
|
...HIDEABLE_COMPANY_PAGES,
|
|
...HIDEABLE_COMPANY_SECTIONS,
|
|
...HIDEABLE_GENERAL_SECTIONS,
|
|
...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey),
|
|
];
|
|
|
|
/** Stable 403 code for writes to operator-hidden settings. */
|
|
export const SETTINGS_OPERATOR_MANAGED_ERROR_CODE = "settings_operator_managed";
|
|
|
|
export interface ParsedHiddenSettings {
|
|
/** Recognized keys, deduplicated, in input order. */
|
|
hidden: HideableSettingKey[];
|
|
/** Unrecognized entries, for the caller to warn about. */
|
|
unknown: string[];
|
|
}
|
|
|
|
/** Parse a `PAPERCLIP_HIDDEN_SETTINGS`-style comma-separated list. */
|
|
export function parseHiddenSettingsList(raw: string | undefined): ParsedHiddenSettings {
|
|
const hidden: HideableSettingKey[] = [];
|
|
const unknown: string[] = [];
|
|
if (!raw) return { hidden, unknown };
|
|
const known = new Set<string>(HIDEABLE_SETTING_KEYS);
|
|
const seen = new Set<string>();
|
|
for (const part of raw.split(",")) {
|
|
const key = part.trim();
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
if (known.has(key)) {
|
|
hidden.push(key as HideableSettingKey);
|
|
} else {
|
|
unknown.push(key);
|
|
}
|
|
}
|
|
return { hidden, unknown };
|
|
}
|
|
|
|
export function hidesInstancePage(
|
|
hidden: ReadonlySet<string>,
|
|
page: HideableInstancePage,
|
|
): boolean {
|
|
return hidden.has(page);
|
|
}
|
|
|
|
export function hidesCompanyPage(
|
|
hidden: ReadonlySet<string>,
|
|
page: HideableCompanyPage,
|
|
): boolean {
|
|
return hidden.has(page);
|
|
}
|
|
|
|
export function hidesCompanySection(
|
|
hidden: ReadonlySet<string>,
|
|
section: HideableCompanySection,
|
|
): boolean {
|
|
return hidden.has(section);
|
|
}
|
|
|
|
export function hidesGeneralSection(
|
|
hidden: ReadonlySet<string>,
|
|
section: HideableGeneralSection,
|
|
): boolean {
|
|
return hidden.has(section);
|
|
}
|
|
|
|
/**
|
|
* Whether a toggle is hidden, either individually or because the whole
|
|
* Experimental page is hidden.
|
|
*/
|
|
export function hidesExperimentalSetting(
|
|
hidden: ReadonlySet<string>,
|
|
key: InstanceFeatureKey,
|
|
): boolean {
|
|
return hidden.has("instance.experimental") || hidden.has(experimentalSettingKey(key));
|
|
}
|