Files
PaperClipAI/packages/db/src/adapter-auth-sessions-schema.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

134 lines
5.6 KiB
TypeScript

import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ADAPTER_AUTH_SESSION_ACTIVE_STATES,
adapterAuthSessions,
} from "./schema/adapter_auth_sessions.js";
type PgTable = Parameters<typeof getTableConfig>[0];
const ACTIVE_INDEX = "adapter_auth_sessions_company_owner_adapter_active_uq";
function findIndex(table: PgTable, indexName: string) {
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
}
function indexColumns(table: PgTable, indexName: string): string[] {
const index = findIndex(table, indexName);
if (!index) return [];
return index.config.columns.map((column) => (column as { name: string }).name);
}
function column(table: PgTable, columnName: string) {
return getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
}
function columnIsNotNull(table: PgTable, columnName: string): boolean {
return column(table, columnName)?.notNull ?? false;
}
// The migration that re-scopes the active index. The test locates it by content,
// so a later re-generation with a different name does not break the test.
function activeIndexMigrationSql(): string {
const migrationsDir = new URL("./migrations/", import.meta.url);
const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql"));
for (const name of files) {
const content = fs.readFileSync(new URL(name, migrationsDir), "utf8");
if (content.includes(ACTIVE_INDEX)) {
return content;
}
}
throw new Error(`no migration creates ${ACTIVE_INDEX}`);
}
describe("adapter auth sessions schema", () => {
it("serializes the active slot on company, owner, and adapter", () => {
const active = findIndex(adapterAuthSessions, ACTIVE_INDEX);
expect(active).toBeDefined();
expect(active?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, ACTIVE_INDEX)).toEqual([
"company_id",
"started_by_user_id",
"adapter_type",
]);
// The active index is partial; the where clause is present.
expect(active?.config.where).toBeDefined();
});
it("keeps the owner principal non-null and drops the environment from the key", () => {
expect(columnIsNotNull(adapterAuthSessions, "started_by_user_id")).toBe(true);
const activeColumns = indexColumns(adapterAuthSessions, ACTIVE_INDEX);
expect(activeColumns).toContain("started_by_user_id");
expect(activeColumns).not.toContain("environment_id");
});
it("adds the public session id, non-null and unique", () => {
expect(column(adapterAuthSessions, "public_session_id")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "public_session_id")).toBe(true);
const uniqueOnPublicSessionId = findIndex(
adapterAuthSessions,
"adapter_auth_sessions_public_session_id_uq",
);
expect(uniqueOnPublicSessionId).toBeDefined();
expect(uniqueOnPublicSessionId?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_public_session_id_uq")).toEqual([
"public_session_id",
]);
});
it("adds the claim marker, nullable", () => {
// A row starts with no claim consumption. The service fills `bound_at` only
// when the create path consumes the stored claim.
expect(column(adapterAuthSessions, "bound_at")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "bound_at")).toBe(false);
});
it("merges the two state unions and drops the persisting state", () => {
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).toEqual([
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
]);
// The removed state stays out of the active set.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("persisting");
// The one-time claim state and the terminal states do not hold the slot.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("stored");
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("completed");
});
it("holds no secret-bearing column", () => {
// The durable store keeps only ids, the state, the lease reference, and the
// deadlines. It never holds the prompt, the login URL, the browser code, the
// token, or a process chunk.
const names = getTableConfig(adapterAuthSessions).columns.map((c) => c.name);
const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"];
for (const name of names) {
for (const needle of forbidden) {
expect(name.includes(needle)).toBe(false);
}
}
});
it("generates the partial unique index over the merged active states", () => {
const sql = activeIndexMigrationSql();
expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`);
expect(sql).toContain('("company_id","started_by_user_id","adapter_type")');
expect(sql).toContain(
"'starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting'",
);
// The new non-null column and the unique index are in the migration.
expect(sql).toContain('"public_session_id" varchar(128) NOT NULL');
expect(sql).toContain(`CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq"`);
// The removed state and the non-active states stay out of the predicate.
const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX));
const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";"));
expect(whereClause).not.toContain("'persisting'");
expect(whereClause).not.toContain("'stored'");
expect(whereClause).not.toContain("'completed'");
expect(whereClause).not.toContain("'authenticated'");
});
});