mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Claude local adapter supports subscription login through a sandbox > - The new-agent page must show login before the user creates an agent > - Test results must not expose raw sandbox diagnostics or secret values > - This pull request adds the login UI to both Test lanes and closes the diagnostic boundary > - The branch also adds durable cleanup recovery for failed sandbox teardown > - Reusable sandboxes must retain both their recorded teardown configuration and a valid lifecycle path until destruction succeeds > - The benefit is a usable login flow with fixed public checks, redacted server logs, and recoverable sandbox cleanup ## Linked Issues or Issue Description Related public work: [#9488](https://github.com/paperclipai/paperclip/pull/9488) adds first-class recognition for `CLAUDE_CODE_OAUTH_TOKEN` in headless and remote runs. Related public issue: [#2681](https://github.com/paperclipai/paperclip/issues/2681) requests Claude Code subscription support. This pull request adds the login transport and new-agent UI flow that those changes do not provide. **Subsystem affected:** Claude local adapter, server login probes, sandbox provider setup, cleanup recovery, and the new-agent UI. **Problem or motivation:** The Test lanes did not show the sandbox login panel in all supported cases. Test results also exposed raw probe diagnostics, and JSON escapes could end secret redaction early. **Proposed solution:** Surface the login capability through the bundled provider manifest. Prepare the same probe runtime in the ACP lane. Send diagnostics only to redacted server logs. Keep Test checks on fixed public messages. Normalize login URL hints to allowlisted HTTPS Claude and Anthropic hosts. Consume JSON escapes during redaction. Preserve failed sandbox cleanup state across retries and restarts, and prevent deletion from severing the lifecycle context of a live reusable sandbox. **Alternatives considered:** Keep raw diagnostics in Test checks or trust login URL text from the sandbox. Both choices increase information exposure. Keep separate probe behavior in the ACP lane. That choice would leave the two Test lanes inconsistent. ## What Changed - Surface the sandbox login panel on both Test lanes. - Reconcile the bundled Daytona plugin manifest so `supportsSetupTokenLogin` reaches the UI capability gate. - Prepare the ACP Test lane with the same probe runtime as the CLI Test lane. - Add the `claude_acp_login_probe_unavailable` warning when the ACP probe cannot run. - Send raw sandbox diagnostics only to redacted server logs. - Keep Test checks on fixed public messages in the ACP, managed-config, and CLI paths. - Normalize login URL hints to allowlisted HTTPS Claude and Anthropic hosts. - Redact JSON and escaped-JSON secret values, including escaped quotes and backslashes. - Preserve orphan cleanup records across provider failures, restarts, and unavailable plugins. - Atomically block environment deletion while a live reusable sandbox lease still depends on it. - Verify pending cleanup destroys plugin sandboxes with the provider configuration recorded on the lease, even after the current environment configuration changes. ## Verification - Head under review: `506b7fa2d83c36bfa5fd722ee9d95b0c7431c241`. - Focused environment route/service/runtime coverage passes: 196 tests across 3 files. - `pnpm -r typecheck` passes. - `pnpm build` passes. - The full Vitest run completed with 4,754 passing and 28 failing tests. All 23 source-test failures reproduce unchanged on parent head `58cfe61a33191ce03d965d65085d26064b4888ba`; the other 5 are duplicate executions from stale `server/dist` output. The failures are unrelated macOS path/listener and scheduler-fixture failures, so there is no new bad commit for bisect to localize. - All required CI checks pass for the current head, including build, typecheck/release registry, all server and workspace shards, serialized server suites, canary, and e2e. - A fresh Greptile review for `506b7fa2d83c36bfa5fd722ee9d95b0c7431c241` reports 5/5, “safe to merge,” with no blocking failure remaining. ## Risks - A probe or redaction change could hide useful server diagnostics. - An allowlist change could reject a valid Claude login URL. - Cleanup recovery changes could affect provider teardown ordering. - An environment with a live reusable sandbox can no longer be deleted until the owning issue or execution workspace completes teardown. - The implementation keeps public Test messages fixed and sends detail to redacted server logs. ## Model Used OpenAI GPT-5 via Codex — exact model ID: GPT-5; tool use and code execution enabled; extended reasoning enabled. The implementation author used AI-assisted development. ## 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 documented the result - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation or confirmed no separate documentation change is needed - [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>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
|
|
/**
|
|
* Bundled plugin auto-provisioning.
|
|
*
|
|
* Managed-cloud instances receive a `plugins.autoInstall` key list through
|
|
* `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed at startup — see
|
|
* `managed-config.ts`). Each key maps to a plugin bundled into
|
|
* the release image under the bundled catalog root. Nobody "installs" on a
|
|
* managed instance: the control plane provisions, tenants use.
|
|
*
|
|
* Two distinct failure postures, deliberately split:
|
|
*
|
|
* 1. **Resolution (this file, `resolveBundledPluginInstalls`) fails to
|
|
* start.** An unknown key or a path that escapes the bundled catalog
|
|
* root is a configuration/security violation — a positive allowlist,
|
|
* not a lookup. Throwing here happens
|
|
* synchronously inside `createApp`, before the server listens, so a bad
|
|
* document refuses to start rather than silently widening what code can
|
|
* be loaded into the host.
|
|
*
|
|
* 2. **Installation (`ensureBundledPlugins`) is fail-safe.**
|
|
* Missing bundle on disk, install error, load error: caught, logged,
|
|
* and swallowed per entry so the server ALWAYS finishes booting. A
|
|
* degraded boot (one provider unavailable) is strictly preferable to a
|
|
* crash loop across a fleet.
|
|
*
|
|
* Removal of a key from `autoInstall` stops future installs but never
|
|
* auto-uninstalls: there is intentionally no uninstall
|
|
* path anywhere in this module.
|
|
*/
|
|
|
|
/** Default location of the bundled plugin catalog inside the release image. */
|
|
export const DEFAULT_BUNDLED_CATALOG_ROOT = "/app/packages/plugins";
|
|
|
|
/**
|
|
* Env var that relocates the bundled catalog root (dev images, tests).
|
|
*/
|
|
export const BUNDLED_CATALOG_ROOT_ENV_VAR = "PAPERCLIP_BUNDLED_PLUGIN_ROOT";
|
|
|
|
export interface BundledPluginCatalogEntry {
|
|
/** Key the managed config's `plugins.autoInstall` list uses. */
|
|
key: string;
|
|
/** Manifest id / registry `pluginKey` the bundle installs as. */
|
|
pluginKey: string;
|
|
/** Bundle location relative to the bundled catalog root. */
|
|
relativePath: string;
|
|
/**
|
|
* Legacy absolute-path override honored for compatibility (the kubernetes
|
|
* bundle predates the catalog). Overrides are still subject to catalog
|
|
* containment when enforcement is on.
|
|
*/
|
|
pathOverrideEnvVar?: string;
|
|
}
|
|
|
|
/**
|
|
* The positive allowlist of plugins the control plane may auto-provision.
|
|
* Keys outside this table can never be installed through this path,
|
|
* regardless of what the managed config document says.
|
|
*/
|
|
export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [
|
|
{
|
|
key: "cloudflare",
|
|
pluginKey: "paperclip.cloudflare-sandbox-provider",
|
|
relativePath: "sandbox-providers/cloudflare",
|
|
},
|
|
{
|
|
key: "daytona",
|
|
pluginKey: "paperclip.daytona-sandbox-provider",
|
|
relativePath: "sandbox-providers/daytona",
|
|
},
|
|
{
|
|
key: "e2b",
|
|
pluginKey: "paperclip.e2b-sandbox-provider",
|
|
relativePath: "sandbox-providers/e2b",
|
|
},
|
|
{
|
|
key: "exe-dev",
|
|
pluginKey: "paperclip.exe-dev-sandbox-provider",
|
|
relativePath: "sandbox-providers/exe-dev",
|
|
},
|
|
{
|
|
key: "kubernetes",
|
|
pluginKey: "paperclip.kubernetes-sandbox-provider",
|
|
relativePath: "sandbox-providers/kubernetes",
|
|
pathOverrideEnvVar: "PAPERCLIP_KUBERNETES_PLUGIN_PATH",
|
|
},
|
|
{
|
|
key: "modal",
|
|
pluginKey: "paperclip.modal-sandbox-provider",
|
|
relativePath: "sandbox-providers/modal",
|
|
},
|
|
{
|
|
key: "novita",
|
|
pluginKey: "paperclip.novita-sandbox-provider",
|
|
relativePath: "sandbox-providers/novita",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Keys ensured on a self-hosted instance (no managed config present).
|
|
* Exactly the pre-refactor behavior: the kubernetes sandbox provider is
|
|
* auto-installed when its bundle is present, nothing else.
|
|
*/
|
|
export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"];
|
|
|
|
export function resolveBundledCatalogRoot(
|
|
env: Record<string, string | undefined>,
|
|
): string {
|
|
const override = env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim();
|
|
return override ? override : DEFAULT_BUNDLED_CATALOG_ROOT;
|
|
}
|
|
|
|
export interface ResolvedBundledPlugin {
|
|
key: string;
|
|
pluginKey: string;
|
|
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
|
|
localPath: string;
|
|
}
|
|
|
|
/**
|
|
* Canonicalize a path for containment comparison. Symlinks are resolved
|
|
* when the path exists so a link inside the catalog cannot point install
|
|
* resolution at a directory outside it; nonexistent paths fall back to a
|
|
* lexical resolve (`..` segments still collapse).
|
|
*/
|
|
function canonicalize(p: string): string {
|
|
const resolved = path.resolve(p);
|
|
try {
|
|
return fs.realpathSync(resolved);
|
|
} catch {
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
function isInsideRoot(candidate: string, root: string): boolean {
|
|
const rel = path.relative(root, candidate);
|
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
}
|
|
|
|
/**
|
|
* Resolve auto-install keys to concrete bundle paths.
|
|
*
|
|
* Throws — and the instance must refuse to start — when a key is not in
|
|
* the bundled catalog, or when `enforceCatalogRoot` is set and the
|
|
* resolved path escapes the catalog root. Callers pass
|
|
* `enforceCatalogRoot: true` for managed (control-plane-driven) key lists
|
|
* and `false` for the self-hosted built-in list, where the legacy
|
|
* kubernetes path override may point anywhere (unchanged behavior).
|
|
*/
|
|
export function resolveBundledPluginInstalls(
|
|
keys: readonly string[],
|
|
opts: {
|
|
catalogRoot: string;
|
|
env: Record<string, string | undefined>;
|
|
enforceCatalogRoot: boolean;
|
|
},
|
|
): ResolvedBundledPlugin[] {
|
|
const resolved: ResolvedBundledPlugin[] = [];
|
|
const seen = new Set<string>();
|
|
const canonicalRoot = canonicalize(opts.catalogRoot);
|
|
for (const key of keys) {
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
|
|
if (!entry) {
|
|
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
|
|
throw new Error(
|
|
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
|
|
);
|
|
}
|
|
const override = entry.pathOverrideEnvVar
|
|
? opts.env[entry.pathOverrideEnvVar]?.trim()
|
|
: undefined;
|
|
const localPath = override
|
|
? path.resolve(override)
|
|
: path.resolve(opts.catalogRoot, entry.relativePath);
|
|
if (opts.enforceCatalogRoot && !isInsideRoot(canonicalize(localPath), canonicalRoot)) {
|
|
throw new Error(
|
|
`bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`,
|
|
);
|
|
}
|
|
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath });
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
interface RegistryPluginRow {
|
|
id: string;
|
|
pluginKey: string;
|
|
status: string;
|
|
version: string;
|
|
manifestJson: PaperclipPluginManifestV1;
|
|
}
|
|
|
|
export interface BundledPluginProvisionerDeps {
|
|
registry: {
|
|
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
|
|
update(
|
|
id: string,
|
|
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
|
|
): Promise<unknown>;
|
|
};
|
|
loader: {
|
|
installPlugin(options: { localPath: string }): Promise<{
|
|
manifest: { id: string } | null;
|
|
}>;
|
|
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
|
|
};
|
|
lifecycle: {
|
|
load(pluginId: string): Promise<unknown>;
|
|
};
|
|
logger: {
|
|
info(obj: unknown, msg?: string): void;
|
|
error(obj: unknown, msg?: string): void;
|
|
};
|
|
/** Overridable for tests; defaults to checking `dist/manifest.js`. */
|
|
bundleManifestExists?: (localPath: string) => boolean;
|
|
}
|
|
|
|
function defaultBundleManifestExists(localPath: string): boolean {
|
|
return fs.existsSync(path.join(localPath, "dist", "manifest.js"));
|
|
}
|
|
|
|
/**
|
|
* Reconcile a present bundled plugin's persisted manifest with the shipped
|
|
* bundle. The bundle is part of the release image, so its manifest is the
|
|
* source of truth. When the bundle declares a version that differs from the
|
|
* persisted version, update the stored manifest and version. This propagates
|
|
* a manifest change (for example a new driver capability) to an existing
|
|
* install that the auto-install path skips.
|
|
*
|
|
* The step is fail-safe. A missing bundle, a manifest read error, or a
|
|
* database error is caught, logged, and swallowed, so boot always completes.
|
|
* The step updates only the stored manifest row; it never restarts the worker.
|
|
*/
|
|
async function reconcileBundledPluginManifest(
|
|
existing: RegistryPluginRow,
|
|
install: ResolvedBundledPlugin,
|
|
deps: BundledPluginProvisionerDeps,
|
|
bundleManifestExists: (localPath: string) => boolean,
|
|
): Promise<void> {
|
|
try {
|
|
if (!bundleManifestExists(install.localPath)) return;
|
|
const bundleManifest = await deps.loader.loadManifest(install.localPath);
|
|
if (!bundleManifest) return;
|
|
if (bundleManifest.version === existing.version) return;
|
|
await deps.registry.update(existing.id, {
|
|
version: bundleManifest.version,
|
|
manifest: bundleManifest,
|
|
});
|
|
deps.logger.info(
|
|
{
|
|
pluginKey: install.pluginKey,
|
|
fromVersion: existing.version,
|
|
toVersion: bundleManifest.version,
|
|
},
|
|
"reconciled bundled plugin manifest to the shipped bundle version",
|
|
);
|
|
} catch (err) {
|
|
deps.logger.error(
|
|
{ err, pluginKey: install.pluginKey },
|
|
"Failed to reconcile bundled plugin manifest; continuing boot with the stored manifest",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure each resolved bundled plugin is installed and loaded.
|
|
*
|
|
* Same mechanism the kubernetes bundle has always used: in-process
|
|
* `loader.installPlugin({ localPath })` at boot — no HTTP, no user, no
|
|
* role. Fully fail-safe per entry: any disk/install/load
|
|
* failure is caught, logged, and swallowed so boot always completes.
|
|
*
|
|
* Skip semantics:
|
|
* - A plugin present in any non-uninstalled state is skipped, so an
|
|
* operator-disabled plugin is not silently re-enabled on reboot. Before the
|
|
* skip, the persisted manifest is reconciled to the shipped bundle version
|
|
* (see `reconcileBundledPluginManifest`).
|
|
* - A soft-uninstalled plugin is reinstalled only when
|
|
* `reinstallUninstalled` is set (managed mode, where the control plane
|
|
* owns provisioning). Self-hosted keeps the pre-refactor behavior of
|
|
* leaving an operator's uninstall alone.
|
|
*/
|
|
export async function ensureBundledPlugins(
|
|
installs: readonly ResolvedBundledPlugin[],
|
|
deps: BundledPluginProvisionerDeps,
|
|
opts: { reinstallUninstalled: boolean },
|
|
): Promise<void> {
|
|
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
|
|
for (const install of installs) {
|
|
try {
|
|
const existing = await deps.registry.getByKey(install.pluginKey);
|
|
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
|
|
// The bundle ships with the release image, so its manifest is the
|
|
// source of truth for a present plugin. Reconcile the persisted
|
|
// manifest when the shipped bundle declares a newer version. Without
|
|
// this step a manifest capability added to a bundle never reaches an
|
|
// existing install, because the auto-install below skips a present
|
|
// plugin. The reconcile updates only the stored manifest row; the
|
|
// running worker already runs the shipped code.
|
|
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
|
|
deps.logger.info(
|
|
{ pluginKey: install.pluginKey, status: existing.status },
|
|
"bundled plugin already present; skipping auto-install",
|
|
);
|
|
continue;
|
|
}
|
|
// Skip silently when the bundle is absent (e.g. local dev or an image
|
|
// built without the plugin). Not an error condition.
|
|
if (!bundleManifestExists(install.localPath)) {
|
|
deps.logger.info(
|
|
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
|
|
"bundled plugin bundle not present; skipping auto-install",
|
|
);
|
|
continue;
|
|
}
|
|
deps.logger.info(
|
|
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
|
|
"auto-installing bundled plugin",
|
|
);
|
|
const discovered = await deps.loader.installPlugin({ localPath: install.localPath });
|
|
if (!discovered.manifest) {
|
|
deps.logger.error(
|
|
{ pluginKey: install.pluginKey },
|
|
"bundled plugin installed but manifest is missing",
|
|
);
|
|
continue;
|
|
}
|
|
// Transition installed -> ready. Whether this also starts the worker
|
|
// depends on the injected lifecycle manager: one built with a
|
|
// runtime-capable loader activates here; the boot-time manager in
|
|
// app.ts is not, so at startup this only records `ready` and the
|
|
// worker is started exactly once by the subsequent loader.loadAll().
|
|
const installed = await deps.registry.getByKey(discovered.manifest.id);
|
|
if (installed) {
|
|
await deps.lifecycle.load(installed.id);
|
|
deps.logger.info(
|
|
{ pluginId: installed.id, pluginKey: installed.pluginKey },
|
|
"bundled plugin auto-installed and loaded",
|
|
);
|
|
} else {
|
|
deps.logger.error(
|
|
{ pluginKey: install.pluginKey },
|
|
"bundled plugin installed but not found in registry",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
deps.logger.error(
|
|
{ err, pluginKey: install.pluginKey },
|
|
"Failed to auto-install bundled plugin; continuing boot (degraded: plugin unavailable)",
|
|
);
|
|
}
|
|
}
|
|
}
|