mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49: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>
134 lines
5.6 KiB
TypeScript
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'");
|
|
});
|
|
});
|