Files
PaperClipAI/server/src/__tests__/smoke-lab.test.ts
T
DottaandPaperclip 931eec3fbf feat(mcp) [split 4/8]: wire gateway runtime and Smoke Lab (#9559)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 4/8 and focuses on gateway runtime, Smoke
Lab, plugins, and server wiring
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: The policy core needs runtime execution, endpoint guards,
route registration, heartbeat integration, and adapter MCP injection to
become operational.
- Proposed solution: Adds the remaining server routes/wiring/consumers,
runtime tests, adapter-utils MCP contracts, and Claude/Codex injection
implementations required by the server layer.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is `pap10341-split/03-server-tool-access`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: SecurityEngineer for gateway, endpoint guard, token
issuance, and runtime wiring; Greptile on every PR.

## What Changed

- Adds the remaining server routes/wiring/consumers, runtime tests,
adapter-utils MCP contracts, and Claude/Codex injection implementations
required by the server layer.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck`
- Changed server test set — 26 files, 382 tests passed
- Affected server adapter tests — 38 tests passed after concrete adapter
boundary move
- Adapter-utils and Codex focused tests — 76 tests passed

## Risks

- Remote endpoint validation, token handling, and runtime supervision
are security-sensitive and can fail closed or deny legitimate access if
misconfigured.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> 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, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools 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] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-14 15:07:30 -05:00

415 lines
15 KiB
TypeScript

import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companies,
companyMemberships,
createDb,
heartbeatRuns,
instanceSettings,
smokeRuns,
smokeRunSteps,
toolApplications,
toolCatalogEntries,
toolConnections,
toolProfileBindings,
toolProfileEntries,
toolProfiles,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { smokeLabRoutes } from "../routes/smoke-lab.js";
import { SMOKE_LAB_OAUTH_SCOPE } from "../services/smoke-lab.js";
import { errorHandler } from "../middleware/index.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type TestDb = ReturnType<typeof createDb>;
async function createCompany(db: TestDb) {
return db.insert(companies).values({
name: `Smoke Lab ${randomUUID()}`,
issuePrefix: `SL${randomUUID().slice(0, 6).toUpperCase()}`,
}).returning().then((rows) => rows[0]!);
}
async function enableSmokeLab(db: TestDb) {
await db.insert(instanceSettings).values({
singletonKey: "default",
experimental: { enableSmokeLab: true },
}).onConflictDoUpdate({
target: [instanceSettings.singletonKey],
set: { experimental: { enableSmokeLab: true }, updatedAt: new Date() },
});
}
async function createAgent(db: TestDb, companyId: string) {
return db.insert(agents).values({
companyId,
name: `Smoke Agent ${randomUUID()}`,
role: "qa",
status: "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning().then((rows) => rows[0]!);
}
function boardActor(companyId?: string): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
userName: "Board User",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
companyIds: companyId ? [companyId] : [],
};
}
function agentActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] {
return {
type: "agent",
companyId,
agentId,
runId,
source: "agent_jwt",
};
}
function createRouteApp(
db: TestDb,
actor?: Express.Request["actor"],
options: Parameters<typeof smokeLabRoutes>[1] = { nodeEnv: "test" },
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor ?? { type: "none", source: "none" };
next();
});
app.use("/api", smokeLabRoutes(db, { nodeEnv: "test", ...options }));
app.use(errorHandler);
return app;
}
describeEmbeddedPostgres("smoke lab service pack and results API", () => {
let db: TestDb;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-smoke-lab-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
vi.unstubAllEnvs();
await db.delete(activityLog);
await db.delete(smokeRunSteps);
await db.delete(smokeRuns);
await db.delete(toolProfileBindings);
await db.delete(toolProfileEntries);
await db.delete(toolProfiles);
await db.delete(toolCatalogEntries);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
await db.delete(authUsers);
await db.delete(instanceSettings);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("gates smoke lab behind the experimental flag and public exposure, not auth mode or NODE_ENV", async () => {
const company = await createCompany(db);
// Flag off -> hidden (404) regardless of deployment.
await request(createRouteApp(db, boardActor(company.id)))
.get(`/api/companies/${company.id}/smoke-lab/services`)
.expect(404);
await enableSmokeLab(db);
// Public exposure is the only disallowed deployment -> 403.
await request(createRouteApp(db, boardActor(company.id), { deploymentMode: "authenticated", deploymentExposure: "public" }))
.get(`/api/companies/${company.id}/smoke-lab/services`)
.expect(403);
// Authenticated + private (e.g. a Tailscale dev box) is allowed, even when the
// instance runs a production Node build.
await request(createRouteApp(db, boardActor(company.id), { deploymentMode: "authenticated", deploymentExposure: "private", nodeEnv: "production" }))
.get(`/api/companies/${company.id}/smoke-lab/services`)
.expect(200);
});
it("runs the deterministic fake OAuth code, refresh, userinfo, and revoke flow", async () => {
const company = await createCompany(db);
await enableSmokeLab(db);
const app = createRouteApp(db);
const redirectUri = "http://127.0.0.1/callback";
const page = await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.query({ client_id: "smoke-client", redirect_uri: redirectUri, state: "state-1", response_type: "code" })
.expect(200);
expect(page.text).toContain("SMOKE TEST - not a real provider");
expect(page.text).toContain("smoke@paperclip.test");
expect(page.text).toContain(SMOKE_LAB_OAUTH_SCOPE);
const authorizeBody = {
client_id: "smoke-client",
redirect_uri: redirectUri,
state: "state-1",
response_type: "code",
scope: SMOKE_LAB_OAUTH_SCOPE,
email: "smoke@paperclip.test",
password: "smoke-password",
};
const authorize = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send(authorizeBody)
.expect(302);
const redirected = new URL(authorize.headers.location);
const code = redirected.searchParams.get("code");
expect(code).toMatch(/^smoke_code_/);
expect(redirected.searchParams.get("state")).toBe("state-1");
const token = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/token`)
.type("form")
.send({ grant_type: "authorization_code", code, client_id: "smoke-client", redirect_uri: redirectUri })
.expect(200);
expect(token.body.access_token).toMatch(/^smoke_access_/);
expect(token.body.refresh_token).toMatch(/^smoke_refresh_/);
expect(token.body.scope).toBe(SMOKE_LAB_OAUTH_SCOPE);
const repeatAuthorize = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send(authorizeBody)
.expect(302);
const repeatCode = new URL(repeatAuthorize.headers.location).searchParams.get("code");
expect(repeatCode).toBe(code);
const repeatToken = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/token`)
.type("form")
.send({ grant_type: "authorization_code", code: repeatCode, client_id: "smoke-client", redirect_uri: redirectUri })
.expect(200);
expect(repeatToken.body.access_token).toBe(token.body.access_token);
expect(repeatToken.body.refresh_token).toBe(token.body.refresh_token);
const refreshed = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/token`)
.type("form")
.send({ grant_type: "refresh_token", refresh_token: token.body.refresh_token })
.expect(200);
expect(refreshed.body.access_token).toMatch(/^smoke_access_/);
expect(refreshed.body.scope).toBe(SMOKE_LAB_OAUTH_SCOPE);
const userinfo = await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/userinfo`)
.set("Authorization", `Bearer ${refreshed.body.access_token}`)
.expect(200);
expect(userinfo.body).toMatchObject({ sub: "smoke-user-1", email: "smoke@paperclip.test" });
await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/revoke`)
.type("form")
.send({ token: refreshed.body.access_token })
.expect(200);
await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/userinfo`)
.set("Authorization", `Bearer ${refreshed.body.access_token}`)
.expect(403);
});
it("rejects real-looking vendor scopes at the fake OAuth provider", async () => {
const company = await createCompany(db);
await enableSmokeLab(db);
const app = createRouteApp(db);
const redirectUri = "http://127.0.0.1/callback";
await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.query({ client_id: "smoke-client", redirect_uri: redirectUri, scope: "repo user:email offline_access", response_type: "code" })
.expect(400);
await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send({
client_id: "smoke-client",
redirect_uri: redirectUri,
scope: "repo user:email offline_access",
email: "smoke@paperclip.test",
password: "smoke-password",
})
.expect(400);
});
it("requires a loopback or same-origin HTTP(S) redirect URI before rendering or completing consent", async () => {
const company = await createCompany(db);
await enableSmokeLab(db);
const app = createRouteApp(db);
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip-dev:45439");
// A redirect host that is neither loopback nor the instance's own origin
// could leak fixture authorization codes off the gated deployment.
await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.query({ client_id: "smoke-client", redirect_uri: "http://other-host:45439/callback", response_type: "code" })
.expect(403);
// The instance's own (non-loopback) origin is fine — the smoke lab runs on
// any private instance, e.g. an authenticated Tailscale host.
await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.query({ client_id: "smoke-client", redirect_uri: "http://paperclip-dev:45439/callback", response_type: "code" })
.expect(200);
await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send({
client_id: "smoke-client",
redirect_uri: "http://paperclip-dev:45439/api/tools/oauth/callback",
email: "smoke@paperclip.test",
password: "smoke-password",
})
.expect(302);
await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send({
client_id: "smoke-client",
redirect_uri: "http://127.0.0.2/callback",
email: "smoke@paperclip.test",
password: "smoke-password",
})
.expect(302);
await request(app)
.post(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.type("form")
.send({
client_id: "smoke-client",
redirect_uri: "ftp://localhost/callback",
email: "smoke@paperclip.test",
password: "smoke-password",
})
.expect(400);
});
it("does not trust the Host header as the OAuth redirect origin", async () => {
const company = await createCompany(db);
await enableSmokeLab(db);
const app = createRouteApp(db);
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "");
vi.stubEnv("BETTER_AUTH_URL", "");
vi.stubEnv("BETTER_AUTH_BASE_URL", "");
await request(app)
.get(`/api/companies/${company.id}/smoke-lab/oauth/authorize`)
.set("Host", "attacker.example")
.query({
client_id: "smoke-client",
redirect_uri: "http://attacker.example/callback",
response_type: "code",
})
.expect(403);
});
it("installs smoke fixtures idempotently into tool access tables", async () => {
const company = await createCompany(db);
await enableSmokeLab(db);
const app = createRouteApp(db, boardActor(company.id));
const first = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/install-fixtures`)
.expect(201);
const second = await request(app)
.post(`/api/companies/${company.id}/smoke-lab/install-fixtures`)
.expect(200);
expect(first.body.created).toBe(true);
expect(second.body.created).toBe(false);
expect(first.body.applications).toHaveLength(2);
expect(first.body.connections).toHaveLength(2);
expect(first.body.catalog.length).toBeGreaterThanOrEqual(6);
expect(first.body.profileEntries.every((entry: { toolName: string }) => entry.toolName.includes(".") || entry.toolName)).toBe(true);
const applications = await db.select().from(toolApplications).where(eq(toolApplications.companyId, company.id));
const connections = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id));
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.companyId, company.id));
const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id));
expect(applications).toHaveLength(2);
expect(connections).toHaveLength(2);
expect(catalog.some((entry) => entry.toolName === "todo.add" && entry.riskLevel === "write")).toBe(true);
expect(catalog.some((entry) => entry.toolName === "time.now" && entry.riskLevel === "read")).toBe(true);
expect(profiles).toHaveLength(1);
});
it("creates runs and lets an agent JWT actor record step results", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
await enableSmokeLab(db);
const boardApp = createRouteApp(db, boardActor(company.id));
const created = await request(boardApp)
.post(`/api/companies/${company.id}/smoke-lab/runs`)
.send({ trigger: "manual", summary: { scenario: "P1" } })
.expect(201);
const runId = created.body.run.id;
const [heartbeatRun] = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: agent.id,
invocationSource: "manual",
status: "running",
}).returning();
const agentApp = createRouteApp(db, agentActor(company.id, agent.id, heartbeatRun!.id));
const step = await request(agentApp)
.post(`/api/companies/${company.id}/smoke-lab/runs/${runId}/steps`)
.send({
path: "P1",
scenarioStep: "oauth-login",
status: "pass",
detail: "OAuth login completed",
screenshotArtifactRef: { provider: "paperclip", attachmentId: randomUUID() },
durationMs: 42,
})
.expect(201);
expect(step.body.step).toMatchObject({ path: "P1", scenarioStep: "oauth-login", status: "pass" });
expect(step.body.summary).toMatchObject({ totalSteps: 1, passedSteps: 1, failedSteps: 0 });
const fetched = await request(boardApp)
.get(`/api/companies/${company.id}/smoke-lab/runs/${runId}`)
.expect(200);
expect(fetched.body.steps).toHaveLength(1);
await request(boardApp)
.patch(`/api/companies/${company.id}/smoke-lab/runs/${runId}`)
.send({ status: "passed", summary: { totalSteps: 1, passedSteps: 1 } })
.expect(200);
await request(agentApp)
.post(`/api/companies/${company.id}/smoke-lab/runs/${runId}/steps`)
.send({ path: "P1", scenarioStep: "late", status: "pass" })
.expect(409);
});
});