mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01: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>
111 lines
6.1 KiB
TypeScript
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),
|
|
}),
|
|
);
|