Files
PaperClipAI/server/src/__tests__/heartbeat-local-environment.test.ts
T
Nicky LeachandPaperclip dc30dc4f34 fix(setup-token): pin the start guard to the served adapter (#12179)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox agents use adapter login routes to start authenticated
sessions
> - The setup-token start route accepted adapter types that later routes
and cleanup did not serve
> - This mismatch could create sessions that no route or reaper could
reach
> - The OpenAPI body schema and synchronous login capability defaults
also differed from the enforced behavior
> - This pull request pins the start guard to the served adapter, shares
the adapter constant, aligns the schema, and exposes login capabilities
early
> - The benefit is consistent session access, cleanup, API
documentation, and login UI behavior

## Linked Issues or Issue Description

Refs: #11730

Refs: #11286

**Subsystem affected**

Cross-cutting server and UI login behavior.

**Problem or motivation**

The setup-token start route accepted a non-served adapter type.
Follow-up routes and the reaper only handled the served adapter. This
could create an unreachable session that held its slot. The OpenAPI
schema and early capability defaults also did not match the route
behavior.

**Proposed solution**

Pin the start guard, follow-up key, and reaper filter to one exported
served-adapter constant. Derive the OpenAPI body from the strict shared
schema. Add the login capability projection to synchronous defaults.

**Alternatives considered**

Keep separate adapter constants and add another guard at each follow-up
route. This would preserve duplicate sources of truth and leave future
drift possible.

**Roadmap alignment**

This change supports the Cloud / Sandbox agents milestone in
`ROADMAP.md`.

## What Changed

- Reject a setup-token start request when its adapter type is not the
served adapter.
- Reuse one exported adapter constant for the start guard, follow-up
key, and reaper filter.
- Derive the company adapter login-sessions start body from the strict
shared schema.
- Add the `login` capability projection to the synchronous Claude and
Codex adapter defaults.
- Add regression coverage for the rejected non-served adapter request.

## Verification

- The setup-token route suite passes, including the non-served adapter
regression test.
- The setup-token session-service suite passes.
- The setup-token reaper suite passes.
- The OpenAPI suite passes.
- The server TypeScript check passes.
- The UI TypeScript check passes.
- GitHub Actions must confirm all required checks after pull request
creation.

## Risks

The start route now rejects adapter types that follow-up routes cannot
serve. No database migration exists. Revert the one commit to roll back
the change.

## Model Used

OpenAI Codex, GPT-5, exact runtime model ID not exposed, large context
window, reasoning, tool use, and code execution. The implementing
engineer used 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-25 19:48:44 -07:00

228 lines
7.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
environmentLeases,
environments,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat environment tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function waitForRunToFinish(
heartbeat: ReturnType<typeof heartbeatService>,
runId: string,
timeoutMs = 5_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await heartbeat.getRun(runId);
if (run && !["queued", "running"].includes(run.status)) return run;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return await heartbeat.getRun(runId);
}
async function waitForRunLeasesToRelease(
db: ReturnType<typeof createDb>,
runId: string,
timeoutMs = 5_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const leases = await db
.select()
.from(environmentLeases)
.where(eq(environmentLeases.heartbeatRunId, runId));
if (leases.length > 0 && leases.every((lease) => lease.status !== "active")) return leases;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return await db
.select()
.from(environmentLeases)
.where(eq(environmentLeases.heartbeatRunId, runId));
}
describeEmbeddedPostgres("heartbeat local environment lifecycle", () => {
let db!: ReturnType<typeof createDb>;
let heartbeat!: ReturnType<typeof heartbeatService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let previousAgentJwtSecret: string | undefined;
beforeAll(async () => {
previousAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
process.env.PAPERCLIP_AGENT_JWT_SECRET = "heartbeat-local-environment-test-secret";
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-local-environment-");
db = createDb(tempDb.connectionString);
heartbeat = heartbeatService(db);
}, 20_000);
afterEach(async () => {
// A run reaches its terminal status before finalizeRun finishes writing
// its trailing lifecycle events and side effects (see the comment on
// drainActiveRunExecutions in heartbeat.ts). Drain those in-flight writes
// before the TRUNCATE below, or a write that lands after the company row
// is gone violates heartbeat_run_events' foreign key.
await heartbeat.drainActiveRunExecutions();
await db.execute(sql.raw(`
TRUNCATE TABLE
"environment_leases",
"environments",
"activity_log",
"heartbeat_run_events",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"company_skills",
"agents",
"companies"
RESTART IDENTITY CASCADE
`));
});
afterAll(async () => {
// Same reasoning as the afterEach drain: closing the embedded database
// while a run's finalize work is still in flight lets a queued write hit
// a socket that cleanup() already tore down.
await heartbeat.drainActiveRunExecutions();
await tempDb?.cleanup();
if (previousAgentJwtSecret === undefined) {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
} else {
process.env.PAPERCLIP_AGENT_JWT_SECRET = previousAgentJwtSecret;
}
});
it("runs work through the default Local environment lease", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "ProcessAgent",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
runtimeConfig: {},
permissions: {},
});
const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual");
expect(queued).not.toBeNull();
const finished = await waitForRunToFinish(heartbeat, queued!.id);
expect(finished?.status).toBe("succeeded");
const localRows = await db
.select()
.from(environments)
.where(eq(environments.driver, "local"));
expect(localRows).toHaveLength(1);
expect(localRows[0]?.name).toBe("Local");
const leases = await waitForRunLeasesToRelease(db, queued!.id);
expect(leases).toHaveLength(1);
expect(leases[0]?.environmentId).toBe(localRows[0]?.id);
expect(leases[0]?.status).toBe("released");
expect(leases[0]?.provider).toBe("local");
expect(leases[0]?.releasedAt).not.toBeNull();
const context = finished?.contextSnapshot as Record<string, unknown>;
expect(context.paperclipEnvironment).toMatchObject({
id: localRows[0]?.id,
name: "Local",
driver: "local",
leaseId: leases[0]?.id,
});
});
it("injects run-scoped Paperclip env into process agents", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const tempDir = await mkdtemp(join(tmpdir(), "paperclip-process-env-"));
const envPath = join(tempDir, "env.json");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "ProcessAgent",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: [
"-e",
[
"const fs = require('node:fs');",
`fs.writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({`,
"agentId: process.env.PAPERCLIP_AGENT_ID ?? null,",
"companyId: process.env.PAPERCLIP_COMPANY_ID ?? null,",
"apiUrl: process.env.PAPERCLIP_API_URL ?? null,",
"runId: process.env.PAPERCLIP_RUN_ID ?? null,",
"apiKeyPresent: Boolean(process.env.PAPERCLIP_API_KEY),",
"}));",
].join(" "),
],
},
runtimeConfig: {},
permissions: {},
});
const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual");
expect(queued).not.toBeNull();
const finished = await waitForRunToFinish(heartbeat, queued!.id);
expect(finished?.status).toBe("succeeded");
const captured = JSON.parse(await readFile(envPath, "utf8")) as Record<string, unknown>;
expect(captured).toMatchObject({
agentId,
companyId,
runId: queued!.id,
apiKeyPresent: true,
});
expect(captured.apiUrl).toEqual(expect.stringMatching(/^https?:\/\//));
});
});