mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## 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>
146 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
});
|