mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Instance settings control optional product features and developer tools. > - The experimental settings page mixed active experiments, internal tools, and old recovery controls. > - Some workspace links also used the selected company instead of the workspace owner. > - These problems made settings hard to scan and could send users to the wrong company route. > - This pull request removes old controls, groups developer settings, and resolves workspace links from workspace data. > - The benefit is a smaller settings surface and correct workspace navigation. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the instance experimental settings page, task watchdog controls, dependency wake recovery, and execution workspace routes. **Current behavior** The settings page shows old recovery controls and mixes product experiments with internal developer settings. Task watchdogs require an extra feature flag. Some direct workspace links use the current company prefix instead of the company that owns the workspace. **Proposed behavior** Remove the old task recovery experiment and its unused API surface. Make task watchdog controls available without the removed flag. Put worktree execution and managed environment controls in the developer section. Resolve direct workspace links from the workspace owner and reject a company prefix that does not own the workspace. **Reason and benefit** The smaller settings page is easier to understand. The server keeps only the dependency wake backstop that it still uses. Workspace links open under the correct company route. **Breaking changes** This removes the experimental issue graph recovery preview and run endpoints. It also removes the task watchdog feature flag. Task watchdog data and dependency wake behavior remain available. ## What Changed - Removed the old task watchdog and issue graph recovery feature flags. - Removed the old issue graph recovery preview, run controls, API contracts, and unused recovery implementation. - Kept resolved dependency wakes as the scheduler backstop. - Grouped product experiments and Paperclip developer settings on the instance settings page. - Made task watchdog controls available without an extra experimental flag. - Added owner-aware redirects and company checks for execution workspace routes. - Hid the false stopped-state badge while a workspace has no active runtime state. - Updated focused server and UI tests for the new behavior. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,620 passing tests and four failures in unchanged workspace runtime port tests. The same four failures repeat when the two files run alone. - The complete GitHub CI matrix passed, including all server, serialized server, build, canary, and end-to-end jobs. ## Risks - Clients that call the removed experimental recovery endpoints must stop calling them. - The route checks depend on workspace detail access. An unknown or cross-company workspace returns the global not-found page. - There are no database migrations, lockfile changes, workflow changes, or design image changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-5. The exact deployment ID and context window are not exposed. Reasoning, tool use, and code execution were enabled. ## 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>
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MANAGED_CONFIG_ENV_KEY,
|
|
getManagedInstanceConfig,
|
|
managedFeatureKeySet,
|
|
parseManagedConfigEnv,
|
|
} from "../services/managed-config.js";
|
|
|
|
function envWith(raw: string | undefined) {
|
|
return { [MANAGED_CONFIG_ENV_KEY]: raw };
|
|
}
|
|
|
|
function validDoc(overrides: Record<string, unknown> = {}) {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enablePipelines: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("managedFeatureKeySet", () => {
|
|
it("contains exactly the boolean flags of the experimental schema", () => {
|
|
const keys = managedFeatureKeySet();
|
|
expect(keys.has("enableApps")).toBe(true);
|
|
expect(keys.has("enableWorktreeRunExecution")).toBe(true);
|
|
// Server-managed bookkeeping fields are not overlayable features.
|
|
expect(keys.has("worktreeRunExecutionActivatedAt")).toBe(false);
|
|
expect(keys.has("worktreeRunExecutionActivationInstanceId")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv", () => {
|
|
it("returns null when the env var is absent (self-hosted)", () => {
|
|
expect(parseManagedConfigEnv({})).toBeNull();
|
|
expect(parseManagedConfigEnv(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("throws when the env var is present but blank (fail closed)", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(""))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith(" "))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith("\n\t"))).toThrow(/is set but blank/);
|
|
});
|
|
|
|
it("parses a complete valid document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enablePipelines: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("accepts the deprecated runner ingress key for managed-config compatibility", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableRunnerPreviewIngress: true } })),
|
|
);
|
|
|
|
expect(config?.features).toEqual({ enableRunnerPreviewIngress: true });
|
|
});
|
|
|
|
it("accepts empty features {} and autoInstall [] sections", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: {}, plugins: { autoInstall: [] } })),
|
|
);
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
plugins: { autoInstall: [] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("throws when the features section is missing (fail closed)", () => {
|
|
const doc = { v: 1, mode: "cloud", catalogVersion: "2026.720.0", plugins: { autoInstall: [] } };
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(doc)))).toThrow(
|
|
/requires a "features" object/,
|
|
);
|
|
});
|
|
|
|
it("throws when the plugins section or autoInstall is missing (fail closed)", () => {
|
|
const noPlugins = {
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
};
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(noPlugins)))).toThrow(
|
|
/requires a "plugins" object/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: {} })))).toThrow(
|
|
/requires a "plugins.autoInstall" array/,
|
|
);
|
|
});
|
|
|
|
it("returns a frozen document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(Object.isFrozen(config)).toBe(true);
|
|
expect(Object.isFrozen(config?.features)).toBe(true);
|
|
expect(Object.isFrozen(config?.plugins.autoInstall)).toBe(true);
|
|
});
|
|
|
|
it("throws on invalid JSON", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("{not json"))).toThrow(
|
|
/PAPERCLIP_MANAGED_CONFIG is not valid JSON/,
|
|
);
|
|
});
|
|
|
|
it("throws on non-object documents", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("[]"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("42"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("null"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith('"cloud"'))).toThrow(/must be a JSON object/);
|
|
});
|
|
|
|
it("throws on an unknown top-level key", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ extra: true })))).toThrow(
|
|
/unknown top-level key "extra"/,
|
|
);
|
|
});
|
|
|
|
it("throws on an unsupported v", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: 2 })))).toThrow(
|
|
/unsupported "v" 2; this build supports v=1/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: "1" })))).toThrow(/unsupported "v"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(JSON.stringify({ mode: "cloud", catalogVersion: "x" })),
|
|
),
|
|
).toThrow(/unsupported "v"/);
|
|
});
|
|
|
|
it("throws on a non-cloud mode", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ mode: "self-hosted" })))).toThrow(
|
|
/invalid "mode" "self-hosted"; expected "cloud"/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, catalogVersion: "x" }))),
|
|
).toThrow(/invalid "mode"/);
|
|
});
|
|
|
|
it("throws on a missing or empty catalogVersion", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, mode: "cloud" }))),
|
|
).toThrow(/non-empty string "catalogVersion"/);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: "" })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: 7 })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
});
|
|
|
|
it("throws on a non-object features section", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ features: ["enableApps"] })))).toThrow(
|
|
/"features" must be an object/,
|
|
);
|
|
});
|
|
|
|
it("throws on an unknown feature key", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableTimeTravel: true } }))),
|
|
).toThrow(/unknown feature key "enableTimeTravel"/);
|
|
// A server-managed bookkeeping field is not an overlayable feature.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { worktreeRunExecutionActivatedAt: true } })),
|
|
),
|
|
).toThrow(/unknown feature key "worktreeRunExecutionActivatedAt"/);
|
|
});
|
|
|
|
it("throws on a feature key the catalog does not mark tier \"managed\"", () => {
|
|
// `enableStreamlinedLeftNavigation` is a real schema flag, but its catalog
|
|
// tier is `preference` (tenant-controllable) — a managed-config document
|
|
// targeting it has incompatible catalog semantics and must fail closed.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableStreamlinedLeftNavigation: true } })),
|
|
),
|
|
).toThrow(
|
|
/"features" key "enableStreamlinedLeftNavigation" has tier "preference".*only tier "managed" keys/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableDecisions: false } }))),
|
|
).toThrow(/has tier "preference"/);
|
|
});
|
|
|
|
it("throws on non-boolean feature values", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: "true" } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: 1 } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: null } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
});
|
|
|
|
it("throws on malformed plugins sections", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: [] })))).toThrow(
|
|
/"plugins" must be an object/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { install: [] } }))),
|
|
).toThrow(/"plugins" has unknown key "install"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: "daytona" } }))),
|
|
).toThrow(/"plugins.autoInstall" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [""] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [" daytona"] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [42] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ plugins: { autoInstall: ["daytona", "daytona"] } })),
|
|
),
|
|
).toThrow(/duplicate entry "daytona"/);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv environments section", () => {
|
|
const entry = (overrides: Record<string, unknown> = {}) => ({
|
|
name: "Daytona",
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
...overrides,
|
|
});
|
|
|
|
it("defaults to an empty list when the section is absent (pre-section documents keep booting)", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config?.environments).toEqual([]);
|
|
});
|
|
|
|
it("parses a declared environment and freezes it", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ description: "Managed Daytona sandbox." })] })),
|
|
);
|
|
expect(config?.environments).toHaveLength(1);
|
|
const spec = config?.environments[0];
|
|
expect(spec?.name).toBe("Daytona");
|
|
expect(spec?.description).toBe("Managed Daytona sandbox.");
|
|
expect(spec?.provider).toBe("daytona");
|
|
expect(spec?.config).toEqual({ target: "us" });
|
|
expect(Object.isFrozen(config?.environments)).toBe(true);
|
|
expect(Object.isFrozen(spec)).toBe(true);
|
|
expect(Object.isFrozen(spec?.config)).toBe(true);
|
|
});
|
|
|
|
it("treats config and description as optional", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [{ name: "Daytona", provider: "daytona" }] })),
|
|
);
|
|
expect(config?.environments[0]?.config).toEqual({});
|
|
expect(config?.environments[0]?.description).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a non-array section and non-object entries", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: {} }))),
|
|
).toThrow(/"environments" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: ["daytona"] }))),
|
|
).toThrow(/"environments\[0\]" must be an object/);
|
|
});
|
|
|
|
it("rejects more than one entry (single managed sandbox slot)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry(), entry({ name: "Other", provider: "kubernetes" })] })),
|
|
),
|
|
).toThrow(/at most one entry/);
|
|
});
|
|
|
|
it("rejects unknown entry keys", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ envVars: {} })] }))),
|
|
).toThrow(/unknown key "envVars"/);
|
|
});
|
|
|
|
it("rejects malformed names, descriptions, and providers", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: "" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: " Daytona" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ description: " " })] }))),
|
|
).toThrow(/"environments\[0\].description"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: 7 })] }))),
|
|
).toThrow(/"environments\[0\].provider"/);
|
|
});
|
|
|
|
it("rejects a provider that plugins.autoInstall does not provision", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: "modal" })] }))),
|
|
).toThrow(/not in "plugins.autoInstall"/);
|
|
});
|
|
|
|
it("rejects a config that sets provider", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { provider: "daytona" } })] })),
|
|
),
|
|
).toThrow(/must not set "provider"/);
|
|
});
|
|
|
|
it("rejects secret-bearing config keys at any depth (secrets travel as env vars)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { apiKey: "not-a-real-key" } })] })),
|
|
),
|
|
).toThrow(/looks secret-bearing/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { auth: { accessToken: "t" } } })] })),
|
|
),
|
|
).toThrow(/auth.accessToken/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(
|
|
validDoc({ environments: [entry({ config: { adapters: [{ clientSecret: "s" }] } })] }),
|
|
),
|
|
),
|
|
).toThrow(/adapters\[0\].clientSecret/);
|
|
});
|
|
});
|
|
|
|
describe("getManagedInstanceConfig", () => {
|
|
it("caches by raw env value and reparses when it changes", () => {
|
|
const raw = validDoc();
|
|
const first = getManagedInstanceConfig(envWith(raw));
|
|
const second = getManagedInstanceConfig(envWith(raw));
|
|
expect(second).toBe(first);
|
|
|
|
const changed = getManagedInstanceConfig(
|
|
envWith(validDoc({ catalogVersion: "2026.721.0" })),
|
|
);
|
|
expect(changed?.catalogVersion).toBe("2026.721.0");
|
|
expect(changed).not.toBe(first);
|
|
|
|
expect(getManagedInstanceConfig(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("rethrows parse failures on every call instead of caching them", () => {
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
});
|
|
});
|