Files
PaperClipAI/server/src/services/setup-token-reaper.test.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

146 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createSetupTokenReaper } from "./setup-token-reaper.js";
import {
isTerminalSessionState,
type SetupTokenCleanupIdentity,
type SetupTokenCleanupRecord,
} from "./setup-token-session.js";
const NOW = 5_000;
// An in-memory reaper store. The test seeds records directly, so no database
// runs. The `listReapable` predicate mirrors the production store: a record is
// reapable when its session is terminal, its deadline is past, or its claim is
// already consumed.
function createMemoryStore() {
const rows = new Map<string, SetupTokenCleanupRecord>();
const matches = (row: SetupTokenCleanupRecord, identity: SetupTokenCleanupIdentity) =>
row.companyId === identity.companyId &&
row.ownerUserId === identity.ownerUserId &&
row.adapterType === identity.adapterType;
return {
rows,
seed(record: Partial<SetupTokenCleanupRecord> & Pick<SetupTokenCleanupRecord, "sessionId" | "leaseId">) {
const row: SetupTokenCleanupRecord = {
sessionId: record.sessionId,
companyId: record.companyId ?? "company-1",
ownerUserId: record.ownerUserId ?? "user-1",
adapterType: record.adapterType ?? "claude_local",
environmentId: record.environmentId ?? "env-1",
leaseId: record.leaseId,
deadline: record.deadline ?? 1_000,
state: record.state ?? "failed",
boundAt: record.boundAt ?? null,
};
rows.set(row.sessionId, row);
return row;
},
async listReapable(now: number): Promise<SetupTokenCleanupRecord[]> {
return [...rows.values()].filter(
(row) => isTerminalSessionState(row.state) || row.deadline <= now || row.boundAt !== null,
);
},
async remove(identity: SetupTokenCleanupIdentity): Promise<void> {
const row = rows.get(identity.sessionId);
if (row && matches(row, identity)) rows.delete(identity.sessionId);
},
};
}
interface FakeLeasesOptions {
releaseImpl?: (leaseId: string) => Promise<void>;
}
function createFakeLeases(opts: FakeLeasesOptions = {}) {
const releaseByIdCalls: string[] = [];
return {
releaseByIdCalls,
async releaseById(leaseId: string): Promise<void> {
releaseByIdCalls.push(leaseId);
if (opts.releaseImpl) await opts.releaseImpl(leaseId);
},
};
}
describe("setup-token reaper", () => {
it("releases the lease and removes the record for a reapable session", async () => {
// A prior process crashed with a live record and a lease.
const store = createMemoryStore();
store.seed({ sessionId: "orphan-1", leaseId: "lease-orphan", state: "awaiting_code", deadline: 1_000 });
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const result = await reaper.sweep();
expect(result.released).toBe(1);
expect(result.failed).toBe(0);
expect(leases.releaseByIdCalls).toEqual(["lease-orphan"]);
expect(store.rows.size).toBe(0);
});
it("holds a live, unexpired, unconsumed record and never releases its lease", async () => {
const store = createMemoryStore();
store.seed({
sessionId: "live-1",
leaseId: "lease-live",
state: "awaiting_code",
// The deadline is in the future and the claim is not consumed.
deadline: NOW + 60_000,
boundAt: null,
});
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const result = await reaper.sweep();
expect(result.released).toBe(0);
expect(leases.releaseByIdCalls).toHaveLength(0);
expect(store.rows.has("live-1")).toBe(true);
});
it("keeps the record when the release fails, then clears it on the next sweep", async () => {
const store = createMemoryStore();
store.seed({ sessionId: "orphan-2", leaseId: "lease-orphan-2", state: "failed" });
let attempt = 0;
const leases = createFakeLeases({
releaseImpl: async () => {
attempt += 1;
if (attempt === 1) throw new Error("provider down");
},
});
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
// First sweep: the release rejects, so the record stays for a retry.
const first = await reaper.sweep();
expect(first.released).toBe(0);
expect(first.failed).toBe(1);
expect(store.rows.has("orphan-2")).toBe(true);
// Second sweep: the release confirms, so the record clears.
const second = await reaper.sweep();
expect(second.released).toBe(1);
expect(second.failed).toBe(0);
expect(leases.releaseByIdCalls).toEqual(["lease-orphan-2", "lease-orphan-2"]);
expect(store.rows.size).toBe(0);
});
it("reaps every reapable record in one sweep and stays idempotent on the next", async () => {
const store = createMemoryStore();
store.seed({ sessionId: "orphan-a", leaseId: "lease-a", state: "failed" });
store.seed({ sessionId: "orphan-b", leaseId: "lease-b", state: "cancelled" });
// A consumed stored claim is reapable through the bound marker.
store.seed({ sessionId: "orphan-c", leaseId: "lease-c", state: "stored", deadline: NOW + 60_000, boundAt: 4_000 });
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const first = await reaper.sweep();
expect(first.released).toBe(3);
expect(store.rows.size).toBe(0);
// The interval sweep runs over the now-empty store without an error.
const second = await reaper.sweep();
expect(second.released).toBe(0);
expect(second.failed).toBe(0);
});
});