mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox agents need a safe login path for each supported adapter > - Codex device login and Claude setup-token login used separate session stores and route logic > - Separate stores made session lookup, expiry, and login capability checks harder to keep consistent > - This pull request unifies both flows on one session table and one capability contract > - The benefit is one company-scoped login model with public session identifiers and shared lifecycle rules ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (multiple of the above) **Problem or motivation** Codex and Claude sandbox login used separate session stores and different route paths. This split increased the risk of inconsistent company scoping, session lookup, and cleanup. **Proposed solution** Use `adapter_auth_sessions` for both login flows. Use public session identifiers for API access. Select login behavior from projected adapter capability data. Share the route spine, lease arguments, runner lifecycle, and reaper rules. **Alternatives considered** Keep two session tables and add matching fixes to both routes. This keeps duplicate logic and does not provide one capability contract, so this pull request uses shared infrastructure. **Roadmap alignment** This change supports the shipped Cloud / Sandbox agents milestone in `ROADMAP.md`. ## What Changed - Unify Codex device login and Claude setup-token login on `adapter_auth_sessions`. - Return and look up sessions with company-scoped public session identifiers. - Enforce one active session for each company, owner, and adapter. - Share the login route spine, sandbox lease arguments, runner lifecycle, and missing-auth check. - Add a standalone setup-token reaper with adapter-specific row selection. - Add optional login capability projection for adapters and drive route and UI selection from that data. - Rename the provider flag to `supportsLoginPty` and validate its deprecated alias. - Remove the old Claude setup-token session table and add the required migrations. ## Verification - Server typecheck passed with `tsc`. - Database typecheck passed. - UI typecheck passed with `tsc -b`. - Codex login service and route suites passed. - Setup-token session, route, and reaper suites passed. - Adapter session schema, plugin validator, capability projection, UI render, and Daytona suites passed. - GitHub Actions must confirm the complete CI gate after pull request creation. ## Risks - The migrations remove short-lived in-flight login rows during deployment. A login that spans the migration can continue until its provider lease expires. - The Codex credential store remains company-scoped. A cross-owner credential race remains a documented, board-accepted risk. - API clients that use internal session row identifiers no longer work. The API accepts only public session identifiers. ## Model Used Codex, GPT-5, exact runtime model ID not exposed in this handoff, large context window, reasoning, and repository tool use. The implementing engineer produced the code with AI assistance. ## 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>
293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
/**
|
|
* External adapter plugin loader.
|
|
*
|
|
* Loads external adapter packages from the adapter-plugin-store and returns
|
|
* their ServerAdapterModule instances. The caller (registry.ts) is
|
|
* responsible for registering them.
|
|
*
|
|
* This avoids circular initialization: plugin-loader imports only
|
|
* adapter-utils, never registry.ts.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import type { ServerAdapterModule } from "./types.js";
|
|
import { validateAdapterLoginCapability } from "@paperclipai/adapter-utils";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
import {
|
|
listAdapterPlugins,
|
|
getAdapterPluginsDir,
|
|
getAdapterPluginByType,
|
|
} from "../services/adapter-plugin-store.js";
|
|
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory UI parser cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const uiParserCache = new Map<string, string>();
|
|
|
|
export function getUiParserSource(adapterType: string): string | undefined {
|
|
return uiParserCache.get(adapterType);
|
|
}
|
|
|
|
/**
|
|
* On cache miss, attempt on-demand extraction from the plugin store.
|
|
* Makes the ui-parser.js endpoint self-healing.
|
|
*/
|
|
export function getOrExtractUiParserSource(adapterType: string): string | undefined {
|
|
const cached = uiParserCache.get(adapterType);
|
|
if (cached) return cached;
|
|
|
|
const record = getAdapterPluginByType(adapterType);
|
|
if (!record) return undefined;
|
|
|
|
const packageDir = resolvePackageDir(record);
|
|
const source = extractUiParserSource(packageDir, record.packageName);
|
|
if (source) {
|
|
uiParserCache.set(adapterType, source);
|
|
logger.info(
|
|
{ type: adapterType, packageName: record.packageName, origin: "lazy" },
|
|
"UI parser extracted on-demand (cache miss)",
|
|
);
|
|
}
|
|
return source;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolvePackageDir(record: Pick<AdapterPluginRecord, "localPath" | "packageName">): string {
|
|
return record.localPath
|
|
? path.resolve(record.localPath)
|
|
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
|
|
}
|
|
|
|
function resolvePackageEntryPoint(packageDir: string): string {
|
|
const pkgJsonPath = path.join(packageDir, "package.json");
|
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
|
|
if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) {
|
|
const exp = pkg.exports["."];
|
|
return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js");
|
|
}
|
|
return pkg.main ?? "index.js";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI parser extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SUPPORTED_PARSER_CONTRACT = "1";
|
|
|
|
function extractUiParserSource(
|
|
packageDir: string,
|
|
packageName: string,
|
|
): string | undefined {
|
|
const pkgJsonPath = path.join(packageDir, "package.json");
|
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
|
|
if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) {
|
|
return undefined;
|
|
}
|
|
|
|
const contractVersion = pkg.paperclip?.adapterUiParser;
|
|
if (contractVersion) {
|
|
const major = contractVersion.split(".")[0];
|
|
if (major !== SUPPORTED_PARSER_CONTRACT) {
|
|
logger.warn(
|
|
{ packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` },
|
|
"Adapter declares unsupported UI parser contract version — skipping UI parser",
|
|
);
|
|
return undefined;
|
|
}
|
|
} else {
|
|
logger.info(
|
|
{ packageName },
|
|
"Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)",
|
|
);
|
|
}
|
|
|
|
const uiParserExp = pkg.exports["./ui-parser"];
|
|
const uiParserFile = typeof uiParserExp === "string"
|
|
? uiParserExp
|
|
: (uiParserExp.import ?? uiParserExp.default);
|
|
const uiParserPath = path.resolve(packageDir, uiParserFile);
|
|
|
|
if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) {
|
|
logger.warn(
|
|
{ packageName, uiParserFile },
|
|
"UI parser path escapes package directory — skipping",
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
if (!fs.existsSync(uiParserPath)) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const source = fs.readFileSync(uiParserPath, "utf-8");
|
|
logger.info(
|
|
{ packageName, uiParserFile, size: source.length },
|
|
`Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`,
|
|
);
|
|
return source;
|
|
} catch (err) {
|
|
logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package");
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Load / reload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
|
|
const m = mod as Record<string, unknown>;
|
|
const createServerAdapter = m.createServerAdapter;
|
|
if (typeof createServerAdapter !== "function") {
|
|
throw new Error(
|
|
`Package "${packageName}" does not export createServerAdapter(). ` +
|
|
`Ensure the package's main entry exports a createServerAdapter function.`,
|
|
);
|
|
}
|
|
|
|
const adapterModule = createServerAdapter() as ServerAdapterModule;
|
|
if (!adapterModule || !adapterModule.type) {
|
|
throw new Error(
|
|
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
|
|
);
|
|
}
|
|
|
|
// Fail closed on a malformed login capability. The validator throws a clear
|
|
// error, so the loader rejects the adapter instead of loading it with a
|
|
// partial capability.
|
|
try {
|
|
validateAdapterLoginCapability(adapterModule);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`createServerAdapter() from "${packageName}" returned an invalid login capability: ` +
|
|
`${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
return adapterModule;
|
|
}
|
|
|
|
export async function loadExternalAdapterPackage(
|
|
packageName: string,
|
|
localPath?: string,
|
|
): Promise<ServerAdapterModule> {
|
|
const packageDir = localPath
|
|
? path.resolve(localPath)
|
|
: path.resolve(getAdapterPluginsDir(), "node_modules", packageName);
|
|
|
|
const entryPoint = resolvePackageEntryPoint(packageDir);
|
|
const modulePath = path.resolve(packageDir, entryPoint);
|
|
const uiParserSource = extractUiParserSource(packageDir, packageName);
|
|
|
|
logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package");
|
|
|
|
const mod = await import(pathToFileURL(modulePath).href);
|
|
const adapterModule = validateAdapterModule(mod, packageName);
|
|
|
|
if (uiParserSource) {
|
|
uiParserCache.set(adapterModule.type, uiParserSource);
|
|
}
|
|
|
|
return adapterModule;
|
|
}
|
|
|
|
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
|
|
try {
|
|
return await loadExternalAdapterPackage(record.packageName, record.localPath);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, packageName: record.packageName, type: record.type },
|
|
"Failed to dynamically load external adapter; skipping",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload an external adapter at runtime (dev iteration without server restart).
|
|
* Busts the ESM module cache via a cache-busting query string.
|
|
*/
|
|
export async function reloadExternalAdapter(
|
|
type: string,
|
|
): Promise<ServerAdapterModule | null> {
|
|
const record = getAdapterPluginByType(type);
|
|
if (!record) return null;
|
|
|
|
const packageDir = resolvePackageDir(record);
|
|
const entryPoint = resolvePackageEntryPoint(packageDir);
|
|
const modulePath = path.resolve(packageDir, entryPoint);
|
|
const fileUrl = pathToFileURL(modulePath).href;
|
|
|
|
// Bust ESM module cache so re-import loads fresh code from disk.
|
|
// Query-string trick (?t=...) works in Node; Bun may need the file:// URL
|
|
// to be evicted from its internal registry first.
|
|
try {
|
|
// @ts-expect-error -- Bun internal module cache
|
|
const bunCache = globalThis.Bun?.__moduleCache as Map<string, unknown> | undefined;
|
|
if (bunCache) {
|
|
bunCache.delete(fileUrl);
|
|
bunCache.delete(modulePath);
|
|
}
|
|
} catch {
|
|
// Ignore — query-string fallback still works in Node
|
|
}
|
|
|
|
const cacheBustUrl = `${fileUrl}?t=${Date.now()}`;
|
|
|
|
logger.info(
|
|
{ type, packageName: record.packageName, modulePath, cacheBustUrl },
|
|
"Reloading external adapter (cache bust)",
|
|
);
|
|
|
|
const mod = await import(cacheBustUrl);
|
|
const adapterModule = validateAdapterModule(mod, record.packageName);
|
|
|
|
uiParserCache.delete(type);
|
|
const uiParserSource = extractUiParserSource(packageDir, record.packageName);
|
|
if (uiParserSource) {
|
|
uiParserCache.set(adapterModule.type, uiParserSource);
|
|
}
|
|
|
|
logger.info(
|
|
{ type, packageName: record.packageName, hasUiParser: !!uiParserSource },
|
|
"Successfully reloaded external adapter",
|
|
);
|
|
|
|
return adapterModule;
|
|
}
|
|
|
|
/**
|
|
* Build all external adapter modules from the plugin store.
|
|
*/
|
|
export async function buildExternalAdapters(): Promise<ServerAdapterModule[]> {
|
|
const results: ServerAdapterModule[] = [];
|
|
|
|
const storeRecords = listAdapterPlugins();
|
|
for (const record of storeRecords) {
|
|
const adapter = await loadFromRecord(record);
|
|
if (adapter) {
|
|
results.push(adapter);
|
|
}
|
|
}
|
|
|
|
if (results.length > 0) {
|
|
logger.info(
|
|
{ count: results.length, adapters: results.map((a) => a.type) },
|
|
"Loaded external adapters from plugin store",
|
|
);
|
|
}
|
|
|
|
return results;
|
|
}
|