Files
paperclip/server/src/__tests__/adapter-routes.test.ts
T
Dotta e89d3f7e11 [codex] Add backup endpoint and dev runtime hardening (#4087)
## Thinking Path

> - Paperclip is a local-first control plane for AI-agent companies.
> - Operators need predictable local dev behavior, recoverable instance
data, and scripts that do not churn the running app.
> - Several accumulated changes improve backup streaming, dev-server
health, static UI caching/logging, diagnostic-file ignores, and instance
isolation.
> - These are operational improvements that can land independently from
product UI work.
> - This pull request groups the dev-infra and backup changes from the
split branch into one standalone branch.
> - The benefit is safer local operation, easier manual backups, less
noisy dev output, and less cross-instance auth leakage.

## What Changed

- Added a manual instance database backup endpoint and route tests.
- Streamed backup/restore handling to avoid materializing large payloads
at once.
- Reduced dev static UI log/cache churn and ignored Node diagnostic
report captures.
- Added guarded dev auto-restart health polling coverage.
- Preserved worktree config during provisioning and scoped auth cookies
by instance.
- Added a Discord daily digest helper script and environment
documentation.
- Hardened adapter-route and startup feedback export tests around the
changed infrastructure.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run packages/db/src/backup-lib.test.ts
server/src/__tests__/instance-database-backups-routes.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/adapter-routes.test.ts
server/src/__tests__/dev-runner-paths.test.ts
server/src/__tests__/health-dev-server-token.test.ts
server/src/__tests__/http-log-policy.test.ts
server/src/__tests__/vite-html-renderer.test.ts
server/src/__tests__/workspace-runtime.test.ts
server/src/__tests__/better-auth.test.ts`
- Split integration check: merged after the runtime/governance branch
and before UI branches with no merge conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.

## Risks

- Medium risk: touches server startup, backup streaming, auth cookie
naming, dev health checks, and worktree provisioning.
- Backup endpoint behavior depends on existing board/admin access
controls and database backup helpers.
- No database migrations are included.

> 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, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:08:55 -05:00

186 lines
6.8 KiB
TypeScript

import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
const overridingConfigSchemaAdapter: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
getConfigSchema: async () => ({
version: 1,
fields: [
{
key: "mode",
type: "text",
label: "Mode",
},
],
}),
};
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: [],
source: "local_implicit",
isInstanceAdmin: false,
...actorOverrides,
};
next();
});
app.use("/api", adapterRoutes());
app.use(errorHandler);
return app;
}
describe("adapter routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../middleware/index.js");
const [adapters, registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
registerServerAdapter = adapters.registerServerAdapter;
unregisterServerAdapter = adapters.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
setOverridePaused("claude_local", false);
unregisterServerAdapter("claude_local");
registerServerAdapter(overridingConfigSchemaAdapter);
});
afterEach(() => {
setOverridePaused("claude_local", false);
unregisterServerAdapter("claude_local");
});
it("GET /api/adapters includes capabilities object for each adapter", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
const adapters = Array.isArray(res.body) ? res.body : JSON.parse(res.text);
expect(Array.isArray(adapters)).toBe(true);
expect(adapters.length).toBeGreaterThan(0);
// Every adapter should have a capabilities object
for (const adapter of adapters) {
expect(adapter.capabilities).toBeDefined();
expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean");
expect(typeof adapter.capabilities.supportsSkills).toBe("boolean");
expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean");
expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean");
}
});
it("GET /api/adapters returns correct capabilities for built-in adapters", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// codex_local has instructions bundle + skills + jwt, no materialized skills
// (claude_local is overridden by beforeEach, so check codex_local instead)
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
});
// process adapter should have no local capabilities
const processAdapter = res.body.find((a: any) => a.type === "process");
expect(processAdapter).toBeDefined();
expect(processAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
});
// cursor adapter should require materialized runtime skills
const cursorAdapter = res.body.find((a: any) => a.type === "cursor");
expect(cursorAdapter).toBeDefined();
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
});
it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// http adapter has no listSkills/syncSkills
const httpAdapter = res.body.find((a: any) => a.type === "http");
expect(httpAdapter).toBeDefined();
expect(httpAdapter.capabilities.supportsSkills).toBe(false);
// codex_local has listSkills/syncSkills
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities.supportsSkills).toBe(true);
});
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
const app = createApp();
const active = await request(app).get("/api/adapters/claude_local/config-schema");
expect(active.status, JSON.stringify(active.body)).toBe(200);
expect(active.body).toMatchObject({
fields: [{ key: "mode" }],
});
const paused = await request(app)
.patch("/api/adapters/claude_local/override")
.send({ paused: true });
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
expect([200, 404], JSON.stringify(builtin.body)).toContain(builtin.status);
expect(builtin.body).not.toMatchObject({
fields: [{ key: "mode" }],
});
});
it("rejects signed-in users without org access", async () => {
const app = createApp({
userId: "outsider-1",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: false,
});
const res = await request(app).get("/api/adapters/claude_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(403);
});
});