Files
PaperClipAI/server/src/adapters/plugin-loader.ts
T
Nicky LeachandPaperclip e0b64529b3 feat(auth): normalize agent login in the sandbox onto one session table and a capability contract (#11730)
## 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>
2026-08-19 11:51:31 -07:00

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;
}