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

111 lines
6.1 KiB
TypeScript

import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core";
import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
// The merged state set for the unified login table. One column holds the state
// of both login flows: the device-login flow and the setup-token flow. The set
// joins the two prior state unions into one union. The set drops the dead
// `persisting` state.
//
// The active states hold the company credential slot for one company, owner, and
// adapter. The partial unique index applies to the active states only.
// - `starting`, `waiting_for_user`, `promoting`: the device-login active states.
// - `awaiting_code`, `submitting`: the setup-token active states.
// The non-active states do not hold the slot:
// - `stored`: the one-time setup-token claim. The create path consumes it and
// writes `bound_at`.
// - `authenticated`, `completed`: the terminal success states.
// - `cleanup_pending`: the terminal state whose sandbox delete failed.
// - `failed`, `timed_out`, `cancelled`: the shared terminal failure states.
export type AdapterAuthSessionState =
| AdapterAuthSessionInternalStatus
| "awaiting_code"
| "submitting"
| "stored"
| "completed";
// The active states of the merged set. The partial unique index and the store
// use this list. The list excludes `stored`, every terminal state, and the
// removed `persisting` state.
export const ADAPTER_AUTH_SESSION_ACTIVE_STATES = [
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
] as const satisfies readonly AdapterAuthSessionState[];
// The durable store for an adapter login session. One row tracks one login
// attempt for one adapter in one environment. The row keeps the owner principal,
// the public session id, the provider lease reference, the status, and the
// finish times. The row never stores the prompt, a credential byte, or the raw
// provider secret.
export const adapterAuthSessions = pgTable(
"adapter_auth_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
// The immutable owner principal. The service sets this column one time at
// create and never updates it. The service returns the prompt only to this
// owner. The active-slot index includes this column, so the column stays
// non-null; a null owner escapes a partial unique index.
startedByUserId: text("started_by_user_id").notNull(),
// The opaque public session id. The public API returns this id, and the
// store keys its lookups by it. The column is non-null, length-bounded, and
// unique. The store fills it at create with a CSPRNG value.
publicSessionId: varchar("public_session_id", { length: 128 }).notNull(),
// The provider lease reference for the sandbox. The reaper reads it to retry
// a failed sandbox delete. It is not a public field.
providerLeaseId: text("provider_lease_id"),
// The merged login state. The column is `text`, so it stores every value of
// the merged `AdapterAuthSessionState` set. The compile-time `$type` is the
// merged union, so both login stores share one status type.
status: text("status").$type<AdapterAuthSessionState>().notNull().default("starting"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
// The promotion claim deadline. The service sets this column when it moves the
// row to `promoting`. While the deadline is in the future, the claim is live,
// so the reaper does not terminate the session or release the company slot.
// A null or past deadline means no live claim, so the reaper can reclaim a
// stalled `promoting` row. The service clears the column on every terminal
// transition.
promotionExpiresAt: timestamp("promotion_expires_at", { withTimezone: true }),
// The claim-consumption marker for a `stored` row. The column is null while
// the stored claim is live. The create path sets it one time when it consumes
// the claim. A non-null value marks a consumed claim, so the reaper removes
// the row. A device-login row never sets this column.
boundAt: timestamp("bound_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
// The fixed, non-secret failure code. The public response reads it.
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("adapter_auth_sessions_company_status_idx").on(
table.companyId,
table.status,
),
// Serialize on the company credential slot. Only one active session can hold
// the slot per company, owner, and adapter. The index applies to the active
// states of the merged set. It does not include the environment; the board
// rule scopes the slot to the owner, not the environment.
companyOwnerAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_owner_adapter_active_uq")
.on(table.companyId, table.startedByUserId, table.adapterType)
.where(
sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')`,
),
// The public session id is unique across the table. The store keys its
// lookups by this id.
publicSessionIdUq: uniqueIndex("adapter_auth_sessions_public_session_id_uq").on(
table.publicSessionId,
),
environmentIdx: index("adapter_auth_sessions_environment_idx").on(table.environmentId),
expiresIdx: index("adapter_auth_sessions_expires_idx").on(table.expiresAt),
providerLeaseIdx: index("adapter_auth_sessions_provider_lease_idx").on(table.providerLeaseId),
}),
);