Connect Codex and Claude login to agent computers (#20417)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-09 22:12:49 -08:00
1 parent 95cb3c94c4
commit 8809f44b5e
43 files changed
+762 -141

No files matched your search

+28
View File
@@ -435,3 +435,31 @@ while being read. Return canonical base64, byte size, and a SHA-256 digest in a
before publishing it. Deliverables additionally verify the caller's expected
size and digest. Providers without this operation fail closed; Core never falls
back to reading remote paths on the server. Existing artifact uploads are unchanged.
## Login allocations on an existing computer
A login-capable environment driver can host authorization on a saved agent's
computer. Core checks the company, environment, agent lifecycle, manifest, and
live worker hooks before acquisition. The chosen agent is a transport target;
it does not change the signed-in account's owner or agent grants.
Core acquires this resource with `purpose: "login"`, the fixed command key, and
an absolute expiry. The returned lease must attest an expiry no later than that
deadline. Drivers must declare `creationOperationVersion: 1` and expose the live
`environmentAcquireLease`, `environmentInspectLease`, and `environmentReleaseLease`
hooks so Core can acquire, recover, and clean up the allocation.
Core does not submit a Runner task or mount project volumes.
The plugin keeps the computer binding in durable lease metadata. PTY close stops
and detaches the terminal while preserving its result. Lease release/destroy
removes only the login resources after Core commits the credential or ends the
attempt. It must preserve the computer and unrelated tasks.
The provider may assign a lease ID different from Core's creation operation ID.
Inspection by `creationOperation` must return that exact allocation as
`creationLease`, with its provider ID matching the observation envelope, so a
lost acquisition reply still reaches the existing cleanup path.
Claude uses the existing setup-token output parser. Codex uses the optional
`loginPtyReadCredential` hook to read its fixed auth artifact. Both retain the
existing owner-bound credential completion and durable cleanup. A restart
interrupts an unfinished login; it does not resume the terminal.
@@ -100,3 +100,17 @@ describe("getEnvironmentCapabilities reusable leases default", () => {
expect(capabilities.sandboxProviders.fake.supportsReusableLeases).toBe(false);
});
});
describe("environment login PTY support", () => {
it("uses the declared driver of the selected environment", async () => {
const { environmentSupportsLoginPty } = await import("./environment-support.js");
const capabilities = { ...getEnvironmentCapabilities(["codex_local"]), loginPtyDrivers: { "fixture:computer": { supportsLoginPty: true } } };
expect(environmentSupportsLoginPty({ driver: "plugin", config: { pluginKey: "fixture", driverKey: "computer" } }, capabilities)).toBe(true);
expect(environmentSupportsLoginPty({ driver: "plugin", config: { pluginKey: "other", driverKey: "computer" } }, capabilities)).toBe(false);
expect(environmentSupportsLoginPty({ driver: "local", config: {} }, capabilities)).toBe(false);
expect(environmentSupportsLoginPty({ driver: "plugin", config: { pluginKey: "fixture", driverKey: "computer" } }, capabilities, "grok_local")).toBe(false);
expect(environmentSupportsLoginPty({ driver: "plugin", config: { pluginKey: "fixture", driverKey: "computer" } }, capabilities, "codex_local")).toBe(false);
expect(environmentSupportsLoginPty({ driver: "plugin", config: { pluginKey: "fixture", driverKey: "computer" } }, { ...capabilities, loginPtyDrivers: { "fixture:computer": { supportsLoginPty: true, supportsCodexLogin: true } } }, "codex_local")).toBe(true);
});
});
@@ -59,6 +59,8 @@ export interface EnvironmentProviderCapability {
}
export interface EnvironmentCapabilities {
/** Namespaced plugin driver keys that can host a login on a saved agent. */
loginPtyDrivers?: Record<string, { supportsLoginPty: boolean; supportsCodexLogin?: boolean }>;
adapters: AdapterEnvironmentSupport[];
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
sandboxProviders: Record<SandboxEnvironmentProvider, EnvironmentProviderCapability>;
@@ -200,3 +202,15 @@ export function getEnvironmentCapabilities(
sandboxProviders,
};
}
/** Shared presentation rule for both temporary sandboxes and existing computers. */
export function environmentSupportsLoginPty(environment: { driver: string; config: Record<string, unknown> } | null | undefined, capabilities: EnvironmentCapabilities | null | undefined, adapterType?: string): boolean {
if (!environment || !capabilities) return false;
if (environment.driver === "sandbox") return typeof environment.config.provider === "string" && capabilities.sandboxProviders[environment.config.provider]?.supportsLoginPty === true;
if (environment.driver === "plugin") {
const driver = capabilities.loginPtyDrivers?.[`${environment.config.pluginKey}:${environment.config.driverKey}`];
return driver?.supportsLoginPty === true
&& (adapterType === undefined || adapterType === "claude_local" || (adapterType === "codex_local" && driver.supportsCodexLogin === true));
}
return false;
}
+1
View File
@@ -2626,6 +2626,7 @@ export {
export {
adapterSupportsRemoteManagedEnvironments,
getEnvironmentCapabilities,
environmentSupportsLoginPty,
getAdapterEnvironmentSupport,
isEnvironmentDriverSupportedForAdapter,
isSandboxProviderSupportedForAdapter,
@@ -12,6 +12,7 @@ import {
} from "../adapter-auth-session.js";
import {
adapterAuthSessionResponseSchema,
adapterAuthSessionOwnerResponseSchema,
startAdapterAuthSessionRequestSchema,
} from "../validators/adapter-auth-session.js";
@@ -93,6 +94,10 @@ describe("adapter auth session contract", () => {
prompt: { url: "https://auth.openai.com/codex/device", code: "ABCD-EFGHI" },
};
expect(owner.prompt?.code).toBe("ABCD-EFGHI");
const target = "33333333-3333-4333-8333-333333333333";
expect(adapterAuthSessionOwnerResponseSchema.parse({ ...owner, agentId: target }).agentId).toBe(target);
expect(adapterAuthSessionOwnerResponseSchema.safeParse({ ...owner, agentId: "invalid" }).success).toBe(false);
expect(adapterAuthSessionResponseSchema.safeParse({ ...owner, agentId: target }).success).toBe(false);
});
it("maps the internal promoting state to the public waiting_for_user state", () => {
+4
View File
@@ -225,6 +225,8 @@ export interface CodexAccountBindingClaim {
// The owner read of a login session. It adds the one-time prompt to the public
// response. Only the owner principal that started the session reads this shape.
export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionResponse {
/** The saved computer executing this login; independent of account grants. */
agentId?: string;
aiConnection?: AiConnectionLoginIntent;
prompt: AdapterAuthSessionPrompt | null;
codexAccountBinding?: CodexAccountBindingClaim | null;
@@ -233,6 +235,7 @@ export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionRespo
// The request that starts a login session for one adapter in one environment.
// The owner principal comes from the authenticated caller, not from this body.
export interface StartAdapterAuthSessionRequest {
agentId?: string;
aiConnection?: AiConnectionLoginIntent;
environmentId: string;
adapterType: AgentAdapterType;
@@ -308,6 +311,7 @@ export interface ClaudeSetupTokenSessionResponse {
// the session reads this shape.
export interface ClaudeSetupTokenSessionOwnerResponse
extends ClaudeSetupTokenSessionResponse {
agentId?: string;
aiConnection?: AiConnectionLoginIntent;
panelMode: AdapterAuthPanelMode;
prompt: ClaudeSetupTokenSessionPrompt | null;
@@ -35,6 +35,7 @@ export type AdapterAuthSessionPrompt = z.infer<typeof adapterAuthSessionPromptSc
// The owner read schema. It adds the one-time prompt to the public response.
export const adapterAuthSessionOwnerResponseSchema = adapterAuthSessionResponseSchema.extend({
agentId: z.string().guid().optional(),
prompt: adapterAuthSessionPromptSchema.nullable(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
}).strict();
@@ -42,6 +43,7 @@ export type AdapterAuthSessionOwnerResponse =
z.infer<typeof adapterAuthSessionOwnerResponseSchema>;
export const startAdapterAuthSessionRequestSchema = z.object({
agentId: z.string().guid().optional(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
environmentId: z.string().guid(),
adapterType: z.enum(AGENT_ADAPTER_TYPES),
@@ -31,13 +31,13 @@ describe("claude setup-token session start request", () => {
expect(result.success).toBe(false);
});
it("rejects an agent id: the scope carries no agent id", () => {
it("accepts an optional saved-agent transport target", () => {
const result = startClaudeSetupTokenSessionRequestSchema.safeParse({
environmentId: ENVIRONMENT_ID,
adapterType: "claude_local",
agentId: "22222222-2222-4222-8222-222222222222",
});
expect(result.success).toBe(false);
expect(result.success).toBe(true);
});
});
@@ -96,6 +96,7 @@ describe("claude setup-token owner response and completion response", () => {
it("accepts an owner response with the submitted-browser-code panel mode and a prompt", () => {
const parsed = claudeSetupTokenSessionOwnerResponseSchema.parse({
sessionId: "opaque-base64url-session-id",
agentId: "33333333-3333-4333-8333-333333333333",
environmentId: ENVIRONMENT_ID,
status: "waiting_for_user",
expiresAt: null,
@@ -104,6 +105,8 @@ describe("claude setup-token owner response and completion response", () => {
prompt: { authorizationUrl: "https://example.test/authorize" },
});
expect(parsed.panelMode).toBe("submitted_browser_code");
expect(parsed.agentId).toBe("33333333-3333-4333-8333-333333333333");
expect(claudeSetupTokenSessionOwnerResponseSchema.safeParse({ ...parsed, agentId: "invalid" }).success).toBe(false);
expect(parsed.prompt?.authorizationUrl).toBe("https://example.test/authorize");
});
@@ -39,11 +39,12 @@ export type ClaudeSetupTokenOverwrite =
// rotates the stored value under the captured version, so a concurrent change
// fails closed with a stale conflict.
//
// `.strict()` rejects an extra field, so an agent id never validates. The scope
// carries no agent id: a hire flow with no agent still starts one session. The
// An optional agent id selects an existing computer for plugin environments.
// Temporary sandbox login remains agentless. The
// runtime does not support a caller-supplied session length, so the schema
// exposes no `ttlSeconds` field; a legacy `ttlSeconds` fails the strict parse.
export const startClaudeSetupTokenSessionRequestSchema = z.object({
agentId: z.string().guid().optional(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
environmentId: z.string().guid(),
adapterType: z.enum(AGENT_ADAPTER_TYPES),
@@ -87,6 +88,7 @@ export type ClaudeSetupTokenSessionPrompt =
// public response.
export const claudeSetupTokenSessionOwnerResponseSchema =
claudeSetupTokenSessionResponseSchema.extend({
agentId: z.string().guid().optional(),
panelMode: adapterAuthPanelModeSchema,
prompt: claudeSetupTokenSessionPromptSchema.nullable(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
@@ -63,9 +63,7 @@ function createMemoryReaperStore(): AdapterAuthReaperStore & {
return [...rows.values()].filter((row) => row.status === "cleanup_pending");
},
async listLeaseReferences() {
return [...rows.values()]
.map((row) => row.providerLeaseId)
.filter((value): value is string => value != null);
return [...rows.values()].flatMap(row => row.providerLeaseId ? [{ sessionId: row.id, environmentId: row.environmentId, providerLeaseId: row.providerLeaseId }] : []);
},
async setStatus(input) {
const row = rows.get(input.sessionId);
@@ -132,7 +130,7 @@ describe("device login reaper", () => {
const result = await reaper.sweep();
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "lease-1" }]);
expect(deleteCalls).toEqual([{ sessionId: seeded.id, environmentId, providerLeaseId: "lease-1" }]);
expect(result.expiredTimedOut).toBe(1);
const row = await store.get(seeded.id);
expect(row?.status).toBe("timed_out");
@@ -196,7 +194,7 @@ describe("device login reaper", () => {
const result = await reaper.sweep();
// The stale claim is reclaimed: the reaper deletes the sandbox and times out.
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "lease-1" }]);
expect(deleteCalls).toEqual([{ sessionId: seeded.id, environmentId, providerLeaseId: "lease-1" }]);
expect(result.expiredTimedOut).toBe(1);
expect((await store.get(seeded.id))?.status).toBe("timed_out");
});
@@ -263,7 +261,7 @@ describe("device login reaper", () => {
const environmentId = randomUUID();
// A live session references its own lease. The reaper never deletes it.
store.seed({
id: randomUUID(),
id: "linked-session",
environmentId,
status: "waiting_for_user",
providerLeaseId: "linked-1",
@@ -280,7 +278,7 @@ describe("device login reaper", () => {
const result = await reaper.sweep();
// Only the orphan lease is deleted; the referenced lease is untouched.
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "orphan-1" }]);
expect(deleteCalls).toEqual([{ sessionId: "orphan-session", environmentId, providerLeaseId: "orphan-1" }]);
expect(result.orphanLeasesDeleted).toBe(1);
});
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { environmentService } from "../services/environments.js";
import { adapterAuthSessions, companies, createDb, environmentLeases, environments } from "@paperclipai/db";
import type { AgentAdapterType } from "@paperclipai/shared";
import { DEVICE_LOGIN_URL } from "@paperclipai/adapter-codex-local/server";
@@ -1768,6 +1769,23 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
await started.completed;
});
it("reads a login computer only from the exact company/environment/provider lease", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const sessionId = randomUUID();
// Providers may reuse resource IDs across allocations. The session tag,
// rather than row order, selects the exact login's computer binding.
await db.insert(environmentLeases).values([
{ companyId, environmentId, status: "released", providerLeaseId: "login-operation", metadata: { purpose: "login", agentId: "old-computer", [LOGIN_LEASE_SESSION_TAG_KEY]: randomUUID() } },
{ companyId, environmentId, status: "active", providerLeaseId: "login-operation", metadata: { purpose: "login", agentId: "computer-a", [LOGIN_LEASE_SESSION_TAG_KEY]: sessionId } },
]);
expect(await store.getLoginAgentId!("login-operation", companyId, environmentId, sessionId)).toBe("computer-a");
expect(await store.getLoginAgentId!("login-operation", randomUUID(), environmentId, sessionId)).toBeUndefined();
expect(await store.getLoginAgentId!("login-operation", companyId, randomUUID(), sessionId)).toBeUndefined();
expect(await store.getLoginAgentId!("another-operation", companyId, environmentId, sessionId)).toBeUndefined();
expect(await store.getLoginAgentId!("login-operation", companyId, environmentId, randomUUID())).toBeUndefined();
});
describe("public session id lookup", () => {
// Start one session and read its rows. The service returns the public session
// id, and the store keeps the internal id private.
@@ -1926,7 +1944,7 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
// this row's environment.
const grokSessionId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: randomUUID(),
id: grokSessionId,
companyId,
environmentId,
adapterType: "grok_local",
@@ -1953,7 +1971,7 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
const claudeEnvironmentId = await seedEnvironment(companyId);
const claudeSessionId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: randomUUID(),
id: claudeSessionId,
companyId,
environmentId: claudeEnvironmentId,
adapterType: "claude_local",
@@ -1984,6 +2002,28 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
expect(orphaned.map(lease => lease.providerLeaseId)).toEqual(["grok-lease-1"]);
});
it("releases the exact orphan when historical sessions reuse its provider ID", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const oldSessionId = randomUUID(), sessionId = randomUUID();
const oldLeaseId = randomUUID(), leaseId = randomUUID();
const at = new Date();
await db.insert(adapterAuthSessions).values([
{ id: oldSessionId, publicSessionId: randomUUID(), companyId, environmentId, adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, providerLeaseId: "reused-provider-id", status: "authenticated", finishedAt: at },
{ id: sessionId, publicSessionId: randomUUID(), companyId, environmentId, adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, providerLeaseId: null, status: "failed", finishedAt: at },
]);
await db.insert(environmentLeases).values([
{ id: oldLeaseId, companyId, environmentId, providerLeaseId: "reused-provider-id", status: "released", cleanupStatus: "success", lastUsedAt: at, metadata: { driver: "plugin", purpose: "login", [LOGIN_LEASE_SESSION_TAG_KEY]: oldSessionId } },
{ id: leaseId, companyId, environmentId, providerLeaseId: "reused-provider-id", status: "active", cleanupStatus: "failed", lastUsedAt: new Date(at.getTime() - 1000), metadata: { driver: "plugin", purpose: "login", [LOGIN_LEASE_SESSION_TAG_KEY]: sessionId } },
]);
const releaseRunLease = vi.fn(async ({ lease }: { lease: { id: string } }) => environmentService(db).releaseLease(lease.id, "released", { cleanupStatus: "success" }));
const runtime = createProductionLoginSessionReaperRuntime({ db, environmentRuntime: { getDriver: () => ({ releaseRunLease }) } as never });
const reaper = createDeviceLoginReaper({ store: createDbAdapterAuthSessionStore(db), runtime });
expect((await reaper.sweep()).orphanLeasesDeleted).toBe(1);
expect(releaseRunLease.mock.calls.map(([input]) => input.lease.id)).toEqual([leaseId]);
expect((await environmentService(db).getLeaseById(oldLeaseId))?.cleanupStatus).toBe("success");
expect(await runtime.listTaggedLeases()).toEqual([]);
});
it("carries the CODEX_HOME variable name on the codex_local profile", () => {
// The home variable name has no consuming call site yet in this phase; the
// profile only carries it, ready for a later phase to read it.
@@ -9,6 +9,7 @@ import type { AgentAdapterType } from "@paperclipai/shared";
// fake returns a fixed environment and records the lease-metadata tag write.
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
listBoundCompanyIds: vi.fn(async () => []),
updateLeaseMetadata: vi.fn(),
}));
@@ -117,6 +118,7 @@ describe("createWorkerBoundLoginPtyOpener", () => {
describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
const acquireInput = {
expiresAt: new Date(Date.now() + 60_000),
companyId: "company-1",
environmentId: "env-1",
adapterType: "codex_local" as AgentAdapterType,
@@ -167,7 +169,7 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
expect(databaseWorkPending(db, DELIVERY_QUEUES.adapterLoginCleanup)).toBe(true);
expect(settled).not.toHaveBeenCalled();
});
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {} };
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {}, expiresAt: acquireInput.expiresAt };
const acquireRunLease = vi.fn(async () => ({
lease,
environment: { id: "env-1", driver: "sandbox" },
@@ -190,3 +192,58 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
unsubscribe();
});
});
describe("production device-login provider deadline", () => {
const deadline = new Date(Date.now() + 60_000);
const input = {
companyId: "company-1", environmentId: "env-1", adapterType: "codex_local" as AgentAdapterType,
startedByUserId: "user-a", expiresAt: deadline,
};
function fixture(expiresAt: Date | null | undefined) {
const environment = { id: "env-1", driver: "sandbox", config: { provider: "daytona" } };
mockEnvironmentService.getById.mockResolvedValue(environment);
const tag = mockEnvironmentService.updateLeaseMetadata.mockReset().mockResolvedValue(undefined);
const execute = vi.fn(async () => [{ xid: "42" }]);
const db = installDatabaseWorkSignals({ execute, async transaction<T>(fn: (tx: object) => Promise<T>) { return fn({ execute }); } });
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {}, expiresAt };
const acquireRunLease = vi.fn(async () => ({ lease, environment }));
const releaseRunLease = vi.fn(async () => ({ cleanupStatus: "success" }));
const openLivePtySession = vi.fn(async () => async () => fakeWorkerSession());
const runtime = createProductionLoginSessionRuntime({
db: db as never,
environmentRuntime: { acquireRunLease, getDriver: () => ({ releaseRunLease }) } as never,
assertProviderSupportsLoginPty: async () => {}, openLivePtySession,
});
return { runtime, tag, acquireRunLease, releaseRunLease, openLivePtySession };
}
it.each([
["missing", undefined], ["null", null], ["invalid", new Date("not-a-date")], ["later", new Date(deadline.getTime() + 1)],
] as const)("releases a %s provider expiry before opening the PTY", async (_label, expiresAt) => {
const f = fixture(expiresAt);
const sessionId = randomUUID();
await expect(f.runtime.acquireLoginLease({ ...input, sessionId })).rejects.toThrow("expiry does not bound the session deadline");
expect(f.releaseRunLease).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ status: "failed", lease: expect.objectContaining({ id: "lease-1" }) }));
expect(f.tag).toHaveBeenCalledWith("lease-1", { adapterLoginSessionId: sessionId });
expect(f.openLivePtySession).not.toHaveBeenCalled();
});
it.each([0, -1])("preserves the provider-attested expiry at deadline offset %s", async offset => {
const expiresAt = new Date(deadline.getTime() + offset);
const f = fixture(expiresAt);
await f.runtime.acquireLoginLease({ ...input, sessionId: randomUUID() });
expect(f.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ requestedExpiresAt: deadline }));
expect(f.openLivePtySession).toHaveBeenCalledWith(expect.objectContaining({ expiresAt: expiresAt.toISOString() }));
expect(f.releaseRunLease).not.toHaveBeenCalled();
});
it("retains the tagged cleanup obligation when provider release fails", async () => {
const f = fixture(null);
f.releaseRunLease.mockRejectedValueOnce(new Error("provider unavailable"));
const sessionId = randomUUID();
await expect(f.runtime.acquireLoginLease({ ...input, sessionId })).rejects.toThrow("provider unavailable");
expect(f.tag).toHaveBeenCalledWith("lease-1", { adapterLoginSessionId: sessionId });
expect(f.openLivePtySession).not.toHaveBeenCalled();
});
});
@@ -1508,6 +1508,10 @@ describe("openapi routes", () => {
expect(
(responseSchemas.start.properties as Record<string, unknown>).prompt,
).toBeDefined();
const activeSchema = spec.paths[`${base}/active`].get.responses["200"].content["application/json"].schema;
expect(activeSchema.properties.agentId).toMatchObject({ type: "string", format: "uuid" });
expect(activeSchema.required ?? []).not.toContain("agentId");
expect((responseSchemas.status.properties as Record<string, unknown>).agentId).toBeUndefined();
// The prompt route returns the authorization URL and the optional transport
// advisory. The advisory is present on a non-confidential transport, so the
// client can show a non-blocking disclaimer.
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
import { listReadyPluginEnvironmentDrivers, pluginSupportsLoginPty } from "../services/plugin-environment-driver.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const mockRegistry = vi.hoisted(() => ({
@@ -77,4 +77,49 @@ describe("listReadyPluginEnvironmentDrivers sandbox capability projection", () =
});
expect(drivers[0]?.supportsReusableLeases).toBe(true);
});
it("preserves existing sandbox login presentation without plugin allocation recovery", async () => {
mockRegistry.list.mockResolvedValue([{
id: PLUGIN_ID, pluginKey: PLUGIN_KEY, status: "ready", manifestJson: {
...manifest, environmentDrivers: [{ ...manifest.environmentDrivers![0], supportsLoginPty: true }],
},
}]);
const workerManager = { isRunning: () => true, getWorker: () => ({ supportedMethods: [] }) } as unknown as PluginWorkerManager;
const [driver] = await listReadyPluginEnvironmentDrivers({ db: {} as never, workerManager });
expect(driver.supportsLoginPty).toBe(true);
});
});
describe("live login capability admission and presentation", () => {
const hooks = ["environmentAcquireLease", "environmentInspectLease", "environmentReleaseLease", "loginPtyOpen", "loginPtyInput", "loginPtyStop", "loginPtyClose"];
const driver = { supportsLoginPty: true, creationOperationVersion: 1 as const };
it("fails closed for each missing allocation, recovery, cleanup or transport hook", () => {
for (const missing of hooks) expect(pluginSupportsLoginPty(driver, hooks.filter(hook => hook !== missing))).toBe(false);
expect(pluginSupportsLoginPty({ ...driver, supportsLoginPty: false }, hooks)).toBe(false);
});
it("requires recoverable allocation identity before admitting login", () => {
expect(pluginSupportsLoginPty({ supportsLoginPty: true }, hooks)).toBe(false);
expect(pluginSupportsLoginPty({ supportsLoginPty: true }, [...hooks, "loginPtyReadCredential"], true)).toBe(false);
});
it("requires the narrow credential hook only for Codex", () => {
expect(pluginSupportsLoginPty(driver, hooks)).toBe(true);
expect(pluginSupportsLoginPty(driver, hooks, true)).toBe(false);
expect(pluginSupportsLoginPty(driver, [...hooks, "loginPtyReadCredential"], true)).toBe(true);
});
it.each(["complete", "missing-version", "environmentAcquireLease", "environmentInspectLease", "environmentReleaseLease"] as const)(
"projects the same recovery gate for login presentation: %s", async mode => {
mockRegistry.list.mockResolvedValue([{
id: PLUGIN_ID, pluginKey: PLUGIN_KEY, status: "ready", manifestJson: {
...manifest, environmentDrivers: [{ ...manifest.environmentDrivers![0], ...driver,
kind: "environment_driver", creationOperationVersion: mode === "missing-version" ? undefined : 1,
}],
},
}]);
const methods = [...hooks, "loginPtyReadCredential"].filter(method => method !== mode);
const workerManager = { isRunning: () => true, getWorker: () => ({ supportedMethods: methods }) } as unknown as PluginWorkerManager;
const [projected] = await listReadyPluginEnvironmentDrivers({ db: {} as never, workerManager, kind: "environment_driver" });
expect(projected.supportsLoginPty).toBe(mode === "complete");
expect(projected.supportsCodexLogin).toBe(mode === "complete");
},
);
});
@@ -25,7 +25,7 @@ describe("durable sandbox creation through the runtime entry point", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
let companyId: string, environmentId: string, pluginId: string, runId: string, spool: string;
let creates: number, destroys: number, loseReply: boolean, failDestroy: boolean;
let creates: number, destroys: number, loseReply: boolean, failDestroy: boolean, pluginLogin: boolean, generatedLoginId: boolean, foreignReceipt: boolean;
let beforeReply: (() => Promise<void>) | undefined;
const resources = new Map<string, string>();
let workers: PluginWorkerManager;
@@ -36,7 +36,7 @@ describe("durable sandbox creation through the runtime entry point", () => {
beforeEach(async () => {
await db.execute(sql`truncate sandbox_creation_operations, companies, environments, plugins cascade`);
companyId = randomUUID(); environmentId = randomUUID(); pluginId = randomUUID(); runId = randomUUID();
const agentId = randomUUID(); creates = 0; destroys = 0; loseReply = false; failDestroy = false; beforeReply = undefined; resources.clear();
const agentId = randomUUID(); creates = 0; destroys = 0; loseReply = false; failDestroy = false; pluginLogin = false; generatedLoginId = false; foreignReceipt = false; beforeReply = undefined; resources.clear();
spool = await mkdtemp(join(tmpdir(), "empty-sandbox-web-"));
await db.insert(companies).values({ id: companyId, name: "Creation fixture", issuePrefix: "CRT" });
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", adapterType: "codex_local" });
@@ -55,7 +55,7 @@ describe("durable sandbox creation through the runtime entry point", () => {
if (method === "environmentAcquireLease") {
expect(await db.select().from(sandboxCreationOperations)).toContainEqual(expect.objectContaining({ id: input.creationOperationId, state: "pending" }));
creates++;
const resource = `provider-${input.creationOperationId}`;
const resource = pluginLogin && !generatedLoginId ? input.creationOperationId : `provider-${input.creationOperationId}`;
resources.set(input.creationOperationId, resource);
await beforeReply?.();
if (loseReply) throw new Error("Lost provider reply");
@@ -64,12 +64,15 @@ describe("durable sandbox creation through the runtime entry point", () => {
if (method === "environmentInspectLease") {
const id = input.creationOperation.id;
const resource = resources.get(id);
if (pluginLogin) return resource
? { providerLeaseId: resource, state: "unknown", creationLease: { providerLeaseId: foreignReceipt ? "foreign-resource" : resource, metadata: { purpose: "login", loginCommandKey: "codex" } } }
: { providerLeaseId: id, state: "destroyed" };
if (!resource) return { providerLeaseId: id, state: "unknown" };
return { providerLeaseId: resource, state: "active", creationCleanup: { providerLeaseId: resource,
observedProviderLeaseId: resource, companyId, environmentId, runId: input.creationOperation.runId, attemptId: id,
accountFingerprint: "a".repeat(64), labels: { "paperclip-create-attempt": id } } };
}
if (method === "environmentDestroyLease") {
if (method === "environmentDestroyLease" || (pluginLogin && method === "environmentReleaseLease")) {
destroys++;
if (failDestroy) throw new Error("Fixture teardown failed");
resources.delete(input.leaseMetadata.creationOperationId);
@@ -95,6 +98,61 @@ describe("durable sandbox creation through the runtime entry point", () => {
await db.execute(sql`create or replace function reject_fixture_write() returns trigger language plpgsql as $$ begin raise exception 'Fixture database write unavailable'; end $$`);
await db.execute(sql.raw(`create trigger ${trigger} before insert on ${table} for each row execute function reject_fixture_write()`));
}
async function acquirePluginLogin(heartbeatRunId: string | null = null) {
pluginLogin = true;
await db.update(environments).set({ driver: "plugin", config: { pluginKey: "paperclip.creation-fixture", driverKey: "creation-fixture", driverConfig: {} } }).where(eq(environments.id, environmentId));
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, environmentDrivers: [{ driverKey: "creation-fixture", kind: "environment_driver", displayName: "Computer", creationOperationVersion: 1, configSchema: { type: "object" } }] } }).where(eq(plugins.id, pluginId));
return runtime().acquireRunLease({ companyId, environment: (await environmentService(db).getById(environmentId))!, heartbeatRunId,
issueId: null, persistedExecutionWorkspace: null, purpose: "login", loginCommandKey: "codex", requestedExpiresAt: new Date(Date.now() + 300000) });
}
it.each([false, true])("recovers an interrupted login using its exact receipt (provider-generated ID: %s)", async generated => {
generatedLoginId = generated;
loseReply = true; await expect(acquirePluginLogin()).rejects.toThrow("Lost provider reply");
const [intent] = await db.select().from(sandboxCreationOperations);
expect(intent.metadata).toMatchObject({ purpose: "login", loginCommandKey: "codex", creationHeartbeatRunId: null });
expect(intent.runId).not.toBe(runId);
await due(); await runtime().flushDeferredOrphanCleanups();
const [lease] = await db.select().from(environmentLeases);
expect(lease).toMatchObject({ status: "pending_cleanup", heartbeatRunId: null, providerLeaseId: generated ? `provider-${intent.id}` : intent.id, metadata: { purpose: "login", loginCommandKey: "codex" } });
await runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(lease.id))! });
expect(resources.size).toBe(0); expect(destroys).toBe(1);
});
it.each([false, true])("preserves an actual heartbeat reference during recovery (legacy intent: %s)", async legacy => {
loseReply = true;
await expect(acquirePluginLogin(runId)).rejects.toThrow("Lost provider reply");
const [intent] = await db.select().from(sandboxCreationOperations);
expect(intent.metadata.creationHeartbeatRunId).toBe(runId);
if (legacy) await db.update(sandboxCreationOperations).set({ metadata: sql`metadata - 'creationHeartbeatRunId'` });
await due(); await runtime().flushDeferredOrphanCleanups();
const [lease] = await db.select().from(environmentLeases);
expect(lease.heartbeatRunId).toBe(runId);
await runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(lease.id))! });
expect(resources.size).toBe(0); expect(destroys).toBe(1);
});
it("retains ownership when the inspected lease and its receipt disagree", async () => {
generatedLoginId = true; foreignReceipt = true; loseReply = true;
await expect(acquirePluginLogin()).rejects.toThrow("Lost provider reply");
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([]);
expect((await db.select().from(sandboxCreationOperations))[0].state).toBe("pending");
expect(resources.size).toBe(1); expect(destroys).toBe(0);
foreignReceipt = false;
await due(); await runtime().flushDeferredOrphanCleanups();
const [lease] = await db.select().from(environmentLeases);
await runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(lease.id))! });
expect(resources.size).toBe(0); expect(destroys).toBe(1);
});
it("resolves a never-created login only after its materialization window", async () => {
loseReply = true; await expect(acquirePluginLogin()).rejects.toThrow("Lost provider reply"); resources.clear();
await due(); await runtime().flushDeferredOrphanCleanups();
expect((await db.select().from(sandboxCreationOperations))[0].state).toBe("pending");
await db.update(sandboxCreationOperations).set({ nextAttemptAt: new Date(0), metadata: sql`metadata || '{"creationSettleAt":"2000-01-01T00:00:00Z"}'::jsonb` });
await runtime().flushDeferredOrphanCleanups();
expect((await db.select().from(sandboxCreationOperations))[0].state).toBe("resolved");
expect(destroys).toBe(0);
});
it("can reapply the creation migration without losing a committed intent", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
const [before] = await db.select().from(sandboxCreationOperations);
+1
View File
@@ -757,6 +757,7 @@ export async function createApp(
// start route drives a live login instead of the fixed 503.
const setupTokenLoginTransport = buildSetupTokenLoginTransport({
sandbox: createProductionSetupTokenSandboxProvider({
db,
environments: environmentService(db),
environmentRuntime: environmentRuntimeService(db, {
pluginWorkerManager: workerManager,
+21 -21
View File
@@ -1,3 +1,4 @@
import { assertLoginEnvironmentTarget } from "../services/login-environment.js";
import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "../services/native-runtime/provider-api-placement.js";
import { isObjectJournalBusyError } from "../services/object-byte-journal.js";
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
@@ -152,7 +153,7 @@ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
import { pluginSupportsLoginPty, resolvePluginEnvironmentDriver, resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type {
AdapterEnvironmentCheck,
@@ -753,6 +754,7 @@ export function agentRoutes(
const guardedSetupTokenLeaseManager: SetupTokenLeaseManager = {
async acquire(input): Promise<SetupTokenLease> {
await assertSandboxLoginEnvironment(input.scope.companyId, input.scope.environmentId, {
agentId: input.scope.agentId,
requireSetupTokenLoginProvider: true,
});
return setupTokenLeaseManager.acquire(input);
@@ -1527,23 +1529,9 @@ export function agentRoutes(
async function assertSandboxLoginEnvironment(
companyId: string,
environmentId: string,
options?: { requireSetupTokenLoginProvider?: boolean },
options?: { requireSetupTokenLoginProvider?: boolean; agentId?: string; adapterType?: string },
): Promise<void> {
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
allowedDrivers: ["sandbox"],
});
// Reject an environment that another company owns. A managed sandbox
// environment binds to the companies that the instance provisions it for.
// When the environment binds to companies but not the request company, the
// environment belongs to another company. A login there runs the process in
// a foreign company sandbox, so the guard fails closed. An environment with
// no company binding is instance-global and stays open to every member.
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
throw forbidden("The selected environment belongs to another company.", {
code: "environment_company_mismatch",
});
}
await assertLoginEnvironmentTarget(db, { companyId, environmentId, agentId: options?.agentId, adapterType: options?.adapterType });
// Gate the Claude setup-token login on the provider capability. Only a
// sandbox provider that advertises the setup-token login capability
// implements the setup-token pseudo-terminal methods. The setup-token start
@@ -1565,12 +1553,20 @@ export function agentRoutes(
* login flows share this resolver, so both gates read the same current
* capability.
*/
async function resolveProviderSupportsLoginPty(environmentId: string): Promise<boolean> {
async function resolveProviderSupportsLoginPty(environmentId: string, requireCredentialRead = false): Promise<boolean> {
const environment = await environmentsSvc.getById(environmentId);
const config =
environment?.config && typeof environment.config === "object"
? (environment.config as Record<string, unknown>)
: {};
if (environment?.driver === "plugin") {
if (!options.pluginWorkerManager || typeof config.pluginKey !== "string" || typeof config.driverKey !== "string") return false;
try {
const resolved = await resolvePluginEnvironmentDriver({ db, workerManager: options.pluginWorkerManager, config: config as unknown as import("@paperclipai/shared").PluginEnvironmentConfig });
const methods = options.pluginWorkerManager.getWorker(resolved.plugin.id)?.supportedMethods ?? [];
return pluginSupportsLoginPty(resolved.driver, methods, requireCredentialRead);
} catch { return false; }
}
const provider = typeof config.provider === "string" ? config.provider : "";
const resolved = provider
? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider })
@@ -1601,7 +1597,7 @@ export function agentRoutes(
* so no session row, lease, or pseudo-terminal starts.
*/
async function assertCodexLoginProviderCapability(environmentId: string): Promise<void> {
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
if (!(await resolveProviderSupportsLoginPty(environmentId, true))) {
throw unprocessable(DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
code: DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
});
@@ -3727,7 +3723,7 @@ export function agentRoutes(
// the route on the current provider capability before any session or
// lease state. The lease-acquisition path re-checks it from current
// runtime state before the provider lease.
await assertSandboxLoginEnvironment(companyId, data.environmentId);
await assertSandboxLoginEnvironment(companyId, data.environmentId, { agentId: data.agentId, adapterType: data.adapterType });
await assertCodexLoginProviderCapability(data.environmentId);
},
});
@@ -3745,6 +3741,7 @@ export function agentRoutes(
result = await adapterLoginService.start({
companyId,
environmentId: data.environmentId,
agentId: data.agentId,
adapterType: type,
startedByUserId,
ttlSeconds: data.ttlSeconds,
@@ -6870,6 +6867,8 @@ export function agentRoutes(
assertSandbox: (data) =>
assertSandboxLoginEnvironment(companyId, data.environmentId, {
requireSetupTokenLoginProvider: true,
agentId: data.agentId,
adapterType: data.adapterType,
}),
});
if (!resolved) return;
@@ -6887,6 +6886,7 @@ export function agentRoutes(
ownerUserId,
adapterType,
environmentId,
agentId: data.agentId,
confirmedOverwrite,
aiConnection: data.aiConnection,
managementContext: continuingConnectionManagement(connectionRequestContext(req)),
@@ -6946,7 +6946,7 @@ export function agentRoutes(
? { authorizationUrl: descriptor.loginUrl, transportAdvisory: assessSetupTokenTransport(req) }
: null,
};
res.json({ ...body, ...(descriptor.aiConnection ? { aiConnection: descriptor.aiConnection } : {}) });
res.json({ ...body, ...(descriptor.agentId ? { agentId: descriptor.agentId } : {}), ...(descriptor.aiConnection ? { aiConnection: descriptor.aiConnection } : {}) });
});
router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/setup-token-login-sessions/:sessionId", { personal: true }), async (req, res) => {
+6 -2
View File
@@ -770,7 +770,8 @@ export function environmentRoutes(
workerManager: options.pluginWorkerManager,
recoverMissingWorker: options.recoverMissingPluginWorker,
});
res.json(getEnvironmentCapabilities(
const loginDrivers = await listReadyPluginEnvironmentDrivers({ db, workerManager: options.pluginWorkerManager, kind: "environment_driver" });
const capabilities = getEnvironmentCapabilities(
AGENT_ADAPTER_TYPES,
{
sandboxProviders: Object.fromEntries(pluginDrivers.map((driver) => [
@@ -809,7 +810,10 @@ export function environmentRoutes(
},
])),
},
));
);
res.json({ ...capabilities, loginPtyDrivers: Object.fromEntries(loginDrivers.map(driver => [
`${driver.pluginKey}:${driver.driverKey}`, { supportsLoginPty: driver.supportsLoginPty === true, supportsCodexLogin: driver.supportsCodexLogin === true },
])) });
});
router.get("/environments/:environmentId/custom-image-template", async (req, res) => {
+1 -1
View File
@@ -989,7 +989,7 @@ describe("company-and-environment setup-token route — strict request contract"
const res = await startCompanySession(app, {
environmentId: ENVIRONMENT_ID,
adapterType: "claude_local",
agentId: "44444444-4444-4444-8444-444444444444",
unknownField: "unsupported",
});
expect(res.status).toBe(400);
expect(transport.records).toEqual([]);
@@ -7,6 +7,16 @@ import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
const ENVIRONMENT = { id: "env-1", name: "Sandbox", driver: "sandbox" } as unknown as Environment;
describe("buildLoginLeaseAcquireArgs", () => {
it("keeps plugin probes as ordinary Runner allocations", () => {
const args = buildLoginLeaseAcquireArgs({ metadata: { companyId: "co", environment: { ...ENVIRONMENT, driver: "plugin" }, adapterType: "paperclip_runner" } });
expect(args.purpose).toBeUndefined();
expect(args.loginCommandKey).toBeUndefined();
expect(args.adapterType).toBe("paperclip_runner");
});
it("binds an explicit browser login to its command and Runner target", () => {
const args = buildLoginLeaseAcquireArgs({ browserLogin: true, metadata: { companyId: "co", environment: { ...ENVIRONMENT, driver: "plugin" }, adapterType: "codex_local" }, targetAgentId: "computer" });
expect(args).toMatchObject({ purpose: "login", loginCommandKey: "codex", adapterType: "paperclip_runner", agentId: "computer" });
});
it("sets the fixed lease arguments that both login services share", () => {
const args = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT, adapterType: "codex" },
+22 -14
View File
@@ -1,3 +1,4 @@
import { resolveLoginCommandKey } from "./login-command.js";
import type { Environment } from "@paperclipai/shared";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
@@ -8,9 +9,14 @@ import type { EnvironmentRuntimeService } from "./environment-runtime.js";
*/
export type AcquireLoginLeaseArgs = Parameters<EnvironmentRuntimeService["acquireRunLease"]>[0];
/** A requested deadline is safe only when the provider attests an expiry at or before it. */
export function loginLeaseBoundsDeadline(expiresAt: Date | null | undefined, deadline: number): boolean {
const expiresAtMs = expiresAt instanceof Date ? expiresAt.getTime() : Number.NaN;
return Number.isFinite(expiresAtMs) && Number.isFinite(deadline) && expiresAtMs <= deadline;
}
/**
* The lease metadata that identifies the login sandbox. The helper copies these
* fields to the acquire arguments without a change.
* The company, environment, and provider adapter selected for this allocation.
*/
export interface LoginLeaseMetadata {
companyId: string;
@@ -24,42 +30,44 @@ export interface LoginLeaseMetadata {
* and passes each option through to the acquire arguments.
*/
export interface BuildLoginLeaseAcquireArgsOptions {
/** The lease metadata that identifies the login sandbox. */
/** The environment and adapter selected by the caller. */
metadata: LoginLeaseMetadata;
/**
* The agent that owns the login. The codex flow passes null. The setup-token
* flow passes the target agent.
* The saved computer that executes the command, independently of account
* ownership. Temporary sandbox logins omit this target.
*/
targetAgentId?: string | null;
/** Explicit browser login; ordinary probes keep their execution allocation. */
browserLogin?: boolean;
/**
* Re-check the environment company binding inside the lease insert
* transaction. The setup-token flow sets this to true.
*/
assertCompanyBinding?: boolean;
/**
* The latest time the acquired lease may stay active. The setup-token flow
* passes the session deadline. The codex flow passes nothing.
* The latest time the allocation may stay active. Both browser-login flows
* pass their absolute session deadline.
*/
requestedExpiresAt?: Date | null;
}
/**
* Build the fixed sandbox lease arguments for a login service. Both login
* services acquire a lease with a null issue, a null heartbeat run, and a null
* execution workspace, and both apply the active custom-image template. This
* helper sets those fixed arguments in one place and passes the caller options
* through without a change to the lease behavior.
* Build a one-off allocation without a task run or execution workspace. Browser
* login callers opt into the login purpose; verification probes retain their
* ordinary execution allocation. Sandbox callers keep the image-template path.
*/
export function buildLoginLeaseAcquireArgs(
options: BuildLoginLeaseAcquireArgsOptions,
): AcquireLoginLeaseArgs {
const pluginLogin = options.browserLogin === true && options.metadata.environment.driver === "plugin";
return {
...(pluginLogin ? { purpose: "login" as const, loginCommandKey: resolveLoginCommandKey(options.metadata.adapterType ?? "") } : {}),
companyId: options.metadata.companyId,
environment: options.metadata.environment,
adapterType: options.metadata.adapterType ?? null,
adapterType: pluginLogin ? "paperclip_runner" : options.metadata.adapterType ?? null,
agentId: options.targetAgentId ?? null,
// A null issue, a null heartbeat run, and a null execution workspace disable
// lease reuse, so the login session always runs in a fresh sandbox.
// lease reuse, so this allocation has its own cleanup ownership.
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
+13 -16
View File
@@ -1,10 +1,12 @@
import { orphanLoginLeases } from "./login-cleanup-state.js";
import type { Db } from "@paperclipai/db";
import {
LOGIN_LEASE_SESSION_TAG_KEY,
decodePendingTerminal,
observeSandboxDelete,
terminalCleanupWrite,
type AdapterAuthReaperStore,
type AdapterLoginLeaseReference,
type SandboxDeleteResult,
} from "./device-login-service.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
@@ -27,16 +29,10 @@ import { environmentService } from "./environments.js";
// resolves the terminal public status before it clears the state.
/** The provider lease reference the reaper deletes through. */
export interface LoginLeaseRef {
environmentId: string;
providerLeaseId: string;
}
export type LoginLeaseRef = AdapterLoginLeaseReference;
/** A login-tagged provider lease. The tag carries the session identifier, so the
* reaper matches the lease against the live session rows. */
export interface TaggedLease extends LoginLeaseRef {
sessionId: string;
}
/** A login-tagged lease carries its authoritative internal session identifier. */
export type TaggedLease = LoginLeaseRef;
/**
* The sandbox side the reaper drives. A production runtime binds it to the
@@ -109,6 +105,7 @@ export function createDeviceLoginReaper(deps: DeviceLoginReaperDeps) {
retried += 1;
const observation = await observeSandboxDelete(() =>
runtime.deleteSandbox({
sessionId: row.id,
environmentId: row.environmentId,
providerLeaseId: row.providerLeaseId!,
}),
@@ -178,6 +175,7 @@ export function createDeviceLoginReaper(deps: DeviceLoginReaperDeps) {
const observation = row.providerLeaseId
? await observeSandboxDelete(() =>
runtime.deleteSandbox({
sessionId: row.id,
environmentId: row.environmentId,
providerLeaseId: row.providerLeaseId!,
}),
@@ -211,15 +209,13 @@ export function createDeviceLoginReaper(deps: DeviceLoginReaperDeps) {
store.listLeaseReferences(),
runtime.listTaggedLeases(),
]);
const referencedSet = new Set(referenced);
const referenceKey = (ref: LoginLeaseRef) => JSON.stringify([ref.sessionId, ref.environmentId, ref.providerLeaseId]);
const referencedSet = new Set(referenced.map(referenceKey));
let deleted = 0;
for (const lease of tagged) {
if (referencedSet.has(lease.providerLeaseId)) continue;
if (referencedSet.has(referenceKey(lease))) continue;
const observation = await observeSandboxDelete(() =>
runtime.deleteSandbox({
environmentId: lease.environmentId,
providerLeaseId: lease.providerLeaseId,
}),
runtime.deleteSandbox(lease),
);
// A confirmed delete removes the orphan. A failed delete leaves it for the
// next sweep.
@@ -274,7 +270,8 @@ export function createProductionLoginSessionReaperRuntime(
return { outcome: "not_found" };
}
const leases = await environmentsSvc.listLeases(ref.environmentId);
const lease = leases.find((candidate) => candidate.providerLeaseId === ref.providerLeaseId);
const lease = leases.find(candidate => candidate.providerLeaseId === ref.providerLeaseId
&& candidate.metadata?.[LOGIN_LEASE_SESSION_TAG_KEY] === ref.sessionId);
if (!lease) {
// No live lease row holds this provider lease. The sandbox is already
// released, so the delete is an idempotent confirmation.
+48 -13
View File
@@ -1,10 +1,11 @@
import { assertLoginEnvironmentTarget } from "./login-environment.js";
import { writeLoginCleanup } from "./login-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { adapterAuthSessions } from "@paperclipai/db";
import { adapterAuthSessions, environmentLeases } from "@paperclipai/db";
import type {
AdapterAuthSessionFailure,
AdapterAuthSessionInternalStatus,
@@ -34,7 +35,7 @@ import {
type LoginPtySessionOpener,
} from "@paperclipai/adapter-utils/login-pty-transport";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { buildLoginLeaseAcquireArgs, loginLeaseBoundsDeadline } from "./adapter-login-lease.js";
import { environmentService } from "./environments.js";
import { runDescriptorBoundAuthRead } from "./device-login-credential-read.js";
import {
@@ -132,6 +133,8 @@ export interface LoginSessionLease {
}
export interface AcquireLoginLeaseInput {
agentId?: string;
expiresAt: Date;
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
@@ -212,6 +215,7 @@ export interface LoginSessionActivityEvent {
export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void;
export interface StartDeviceLoginInput {
agentId?: string;
managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
@@ -340,6 +344,7 @@ export interface CompareAndSetAdapterAuthSessionStatusInput
/** The store for the login-session rows. The service inserts, transitions, and
* reads through it. The insert maps the active-slot conflict to a `409`. */
export interface AdapterAuthSessionStore {
getLoginAgentId?(providerLeaseId: string, companyId: string, environmentId: string, sessionId: string): Promise<string | undefined>;
insert(input: InsertAdapterAuthSessionInput): Promise<void>;
recordLeaseAcquired(input: {
sessionId: string;
@@ -449,13 +454,19 @@ export const ADAPTER_AUTH_PROMOTION_CLAIM_MS = 60_000;
* provider leases that no live session references. The db-backed store
* implements this next to {@link AdapterAuthSessionStore}.
*/
export interface AdapterLoginLeaseReference {
sessionId: string;
environmentId: string;
providerLeaseId: string;
}
export interface AdapterAuthReaperStore {
/** The non-terminal sessions with an `expiresAt` at or before `now`. */
listExpiredActiveSessions(now: Date): Promise<AdapterAuthSessionRow[]>;
/** The terminal sessions left in the internal `cleanup_pending` state. */
listCleanupPendingSessions(): Promise<AdapterAuthSessionRow[]>;
/** Every provider lease reference a session row still holds. */
listLeaseReferences(): Promise<string[]>;
/** The exact session/environment/provider references that rows still hold. */
listLeaseReferences(): Promise<AdapterLoginLeaseReference[]>;
setStatus(input: SetAdapterAuthSessionStatusInput): Promise<void>;
/** A conditional status write. It returns true only when it changed one row. */
compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise<boolean>;
@@ -621,6 +632,10 @@ export function createDbAdapterAuthSessionStore(
const row = rows[0];
return row ? toRow(row) : null;
},
async getLoginAgentId(providerLeaseId, companyId, environmentId, sessionId) {
const [lease] = await db.select({ companyId: environmentLeases.companyId, metadata: environmentLeases.metadata }).from(environmentLeases).where(and(eq(environmentLeases.providerLeaseId, providerLeaseId), eq(environmentLeases.companyId, companyId), eq(environmentLeases.environmentId, environmentId), sql`${environmentLeases.metadata}->>${LOGIN_LEASE_SESSION_TAG_KEY} = ${sessionId}`)).limit(1);
return lease?.companyId === companyId && lease.metadata?.purpose === "login" ? (typeof lease.metadata.agentId === "string" ? lease.metadata.agentId : undefined) : undefined;
},
async getActiveByOwner(companyId, startedByUserId, adapterType) {
// The predicate matches the same three columns as the active-slot partial
// unique index, plus the active-status set, so at most one row can match.
@@ -682,7 +697,7 @@ export function createDbAdapterAuthSessionStore(
},
async listLeaseReferences() {
const rows = await db
.select({ providerLeaseId: adapterAuthSessions.providerLeaseId })
.select({ sessionId: adapterAuthSessions.id, environmentId: adapterAuthSessions.environmentId, providerLeaseId: adapterAuthSessions.providerLeaseId })
.from(adapterAuthSessions)
.where(
and(
@@ -690,9 +705,7 @@ export function createDbAdapterAuthSessionStore(
isNotNull(adapterAuthSessions.providerLeaseId),
),
);
return rows
.map((row) => row.providerLeaseId)
.filter((value): value is string => value != null);
return rows.flatMap(row => row.providerLeaseId ? [{ ...row, providerLeaseId: row.providerLeaseId }] : []);
},
async withCompanyAdapterPromotionLock(companyId, startedByUserId, adapterType, fn) {
return withAdapterLoginPromotionLock(db, companyId, startedByUserId, adapterType, fn);
@@ -925,7 +938,8 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
// caller cannot address the row by the internal id.
const publicSessionId = randomUUID();
const startedAt = now();
const ttlSeconds = input.ttlSeconds ?? resolveProfile(input.adapterType).timeoutMs / 1000;
const requestedTtlSeconds = input.ttlSeconds ?? resolveProfile(input.adapterType).timeoutMs / 1000;
const ttlSeconds = input.agentId ? Math.min(requestedTtlSeconds, DEVICE_LOGIN_TIMEOUT_MS / 1000) : requestedTtlSeconds;
const expiresAt = new Date(startedAt.getTime() + ttlSeconds * 1000);
const base = {
sessionId,
@@ -964,6 +978,8 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
environmentId: input.environmentId,
adapterType: input.adapterType,
sessionId,
agentId: input.agentId,
expiresAt,
startedByUserId: input.startedByUserId,
});
} catch (error) {
@@ -1314,17 +1330,19 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
// terminal transition deletes the prompt at its own write site instead (see
// `terminate`, `abandonAfterLostClaim`, and `cancelOwnerSession`), so a
// terminal response always carries a null prompt.
function buildOwnerResponse(
async function buildOwnerResponse(
row: AdapterAuthSessionRow,
requestingUserId: string,
): AdapterAuthSessionOwnerResponse {
): Promise<AdapterAuthSessionOwnerResponse> {
const isOwner = row.startedByUserId === requestingUserId;
const status = resolvePublicStatus(row);
// The prompt map keys on the internal id, so read it by `row.id`, not the
// public id. Only the owner principal ever reads the prompt.
const prompt = isOwner ? promptsBySession.get(row.id) ?? null : null;
const agentId = isOwner && row.providerLeaseId ? await store.getLoginAgentId?.(row.providerLeaseId, row.companyId, row.environmentId, row.id) : undefined;
return {
sessionId: row.publicSessionId,
...(agentId ? { agentId } : {}),
...(isOwner && row.aiConnection ? { aiConnection: row.aiConnection } : {}),
environmentId: row.environmentId,
status,
@@ -1472,6 +1490,7 @@ export function buildSandboxLoginDriver(deps: {
lease: EnvironmentLease;
sessionHome: string;
timeoutMs: number;
readCredential?: () => Promise<Buffer>;
}): SandboxLoginDriver {
const { openPtySession, environmentRuntime, environment, lease, sessionHome, timeoutMs } = deps;
const transport = createLoginPtyTransport(openPtySession);
@@ -1483,6 +1502,7 @@ export function buildSandboxLoginDriver(deps: {
// The read ignores the runner path. It runs one fixed, server-controlled,
// descriptor-bound operation against the verified session home. So a swap of
// the credential file between a check and the read cannot steer the read.
if (deps.readCredential) return deps.readCredential();
return runDescriptorBoundAuthRead({
environmentRuntime,
environment,
@@ -1513,6 +1533,7 @@ export interface LoginPtySessionBinding {
providerLeaseId: string;
/** The server-controlled session home. The opener sets `CODEX_HOME` to it. */
sessionHome: string;
expiresAt?: string;
/** The resolved environment for the acquired lease. */
environment: Environment;
/** The acquired environment lease. */
@@ -1572,7 +1593,7 @@ export function createWorkerBoundLoginPtyOpener(
: {};
const pluginId = readLeaseMetaString(metadata.pluginId);
const driverKey =
readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
readLeaseMetaString(metadata.driverKey) ?? readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
if (!binding.providerLeaseId || !pluginId || !driverKey) {
log("[paperclip] Device login: the lease carries no sandbox worker binding.");
throw new Error(CODEX_LOGIN_PTY_BIND_FAILED);
@@ -1599,6 +1620,7 @@ export function createWorkerBoundLoginPtyOpener(
providerLeaseId: binding.providerLeaseId,
loginCommandKey,
sessionHome: binding.sessionHome,
expiresAt: binding.expiresAt,
});
};
}
@@ -1640,7 +1662,7 @@ export function createProductionLoginSessionRuntime(
const environmentsSvc = environmentService(deps.db);
return {
async acquireLoginLease(input) {
const environment = await environmentsSvc.getById(input.environmentId);
const environment = await assertLoginEnvironmentTarget(deps.db, input);
if (!environment) {
throw new Error(`Environment "${input.environmentId}" is not found.`);
}
@@ -1654,11 +1676,15 @@ export function createProductionLoginSessionRuntime(
await deps.assertProviderSupportsLoginPty(input.environmentId);
const record = await deps.environmentRuntime.acquireRunLease(
buildLoginLeaseAcquireArgs({
browserLogin: true,
metadata: {
companyId: input.companyId,
environment,
adapterType: input.adapterType,
},
targetAgentId: input.agentId,
requestedExpiresAt: input.expiresAt,
assertCompanyBinding: true,
}),
);
// Tag the lease with the session identifier, so the reaper resolves an
@@ -1667,6 +1693,13 @@ export function createProductionLoginSessionRuntime(
...(record.lease.metadata ?? {}),
[LOGIN_LEASE_SESSION_TAG_KEY]: input.sessionId,
}));
// Keep the tagged cleanup obligation even if release fails. A database
// deadline alone cannot stop a provider after this Core process exits.
if (!loginLeaseBoundsDeadline(record.lease.expiresAt, input.expiresAt.getTime())) {
const runtimeDriver = deps.environmentRuntime.getDriver(record.environment.driver);
await runtimeDriver?.releaseRunLease({ environment: record.environment, lease: record.lease, status: "failed" });
throw new Error("Device login failed: the acquired lease expiry does not bound the session deadline.");
}
const sessionHome = sessionLoginHomePath(input.sessionId);
const authPath = sessionCredentialPath(input.sessionId);
const providerLeaseId = record.lease.providerLeaseId ?? record.lease.id;
@@ -1681,6 +1714,7 @@ export function createProductionLoginSessionRuntime(
adapterType: input.adapterType,
providerLeaseId,
sessionHome,
expiresAt: record.lease.expiresAt?.toISOString(),
environment: record.environment,
lease: record.lease,
})
@@ -1692,6 +1726,7 @@ export function createProductionLoginSessionRuntime(
lease: record.lease,
sessionHome,
timeoutMs: DEVICE_LOGIN_TIMEOUT_MS,
...(record.environment.driver === "plugin" ? { readCredential: () => deps.environmentRuntime.readLoginCredential({ environment: record.environment, lease: record.lease }) } : {}),
});
const driverKey = record.environment.driver;
return {
+11 -5
View File
@@ -1,3 +1,4 @@
import { readPluginLoginCredential } from "./plugin-login-credential.js";
import { ENVIRONMENT_LEASE_CONTROLLER_KEY } from "./environment-lease-controller.js";
import { RetainedSandboxAbsentError, resumeReplyConfirmsAbsence } from "./retained-sandbox-absent-error.js";
import { readNativeWorkspaceMigration } from "./native-runtime/native-workspace-migration.js";
@@ -5,7 +6,7 @@ import { ReusableSandboxResumeError } from "./reusable-sandbox-resume-error.js";
import { PluginWorkerUnavailableError } from "./plugin-worker-unavailable-error.js";
import { HttpError } from "../errors.js";
import { captureEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
import { createOwnedSandbox, recoverSandboxCreations, recoverableTaskCreation } from "./sandbox-creation-operations.js";
import { createOwnedSandbox, recoverSandboxCreations, recoverablePluginCreation } from "./sandbox-creation-operations.js";
import { dueRunLeaseReleaseCondition, pendingRunLeaseReleaseCondition } from "./environment-lease-release-state.js";
import { flushDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { executeEnvironmentTask, pluginEnvironmentSupportsTasks, settleDispatchedEnvironmentTask } from "./environment-task-runtime.js";
@@ -3398,7 +3399,8 @@ function createPluginEnvironmentDriver(
issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId,
provider: `plugin:${parsed.config.pluginKey}:${parsed.config.driverKey}`, pluginId: plugin.id, driverKey: parsed.config.driverKey,
config: {}, timeoutMs: rpcTimeoutMs, requestKey: fingerprint,
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: parsed.config.pluginKey, driverKey: parsed.config.driverKey },
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: parsed.config.pluginKey, driverKey: parsed.config.driverKey,
...(input.purpose ? { purpose: input.purpose, loginCommandKey: input.loginCommandKey } : {}) },
}, identity => workerManager.call(plugin.id, "environmentAcquireLease", {
driverKey: parsed.config.driverKey,
companyId: input.companyId,
@@ -4181,8 +4183,8 @@ export function environmentRuntimeService(
async flushDeferredOrphanCleanups(): Promise<{ recovered: number; pending: number }> {
orphanRecoveryFlush ??= (async () => {
await recoverSandboxCreations(db, async operation => {
const taskCreation = recoverableTaskCreation(operation);
if (!operation.pluginId || (operation.metadata.sandboxProviderPlugin !== true && !taskCreation)) return null;
const allocationCreation = recoverablePluginCreation(operation);
if (!operation.pluginId || (operation.metadata.sandboxProviderPlugin !== true && !allocationCreation)) return null;
if (!options.pluginWorkerManager) throw new Error("Sandbox creation recovery worker is unavailable");
const plugin = await pluginRegistryService(db).getById(operation.pluginId);
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === operation.driverKey);
@@ -4191,7 +4193,7 @@ export function environmentRuntimeService(
!options.pluginWorkerManager.getWorker(operation.pluginId)?.supportedMethods.includes("environmentInspectLease")) {
throw new Error("Sandbox creation recovery worker is unavailable");
}
if (taskCreation) return options.pluginWorkerManager.call(operation.pluginId, "environmentInspectLease", {
if (allocationCreation) return options.pluginWorkerManager.call(operation.pluginId, "environmentInspectLease", {
driverKey: operation.driverKey, companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
config: operation.config, providerLeaseId: operation.id, creationOperation: { id: operation.id, runId: operation.runId },
leaseMetadata: { ...operation.metadata, runId: operation.runId },
@@ -4459,6 +4461,10 @@ export function environmentRuntimeService(
return await driver.realizeWorkspace(input);
},
async readLoginCredential(input: { environment: Environment; lease: EnvironmentLease }): Promise<Buffer> {
return readPluginLoginCredential({ db, workerManager: options.pluginWorkerManager, ...input });
},
async execute(input: EnvironmentDriverExecuteInput): Promise<PluginEnvironmentExecuteResult> {
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
if (!driver.execute) {
+1 -1
View File
@@ -18,10 +18,10 @@ export function orphanLoginLeases(db: Db, now = new Date()) {
notExists(db.select({ id: adapterAuthSessions.id }).from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.companyId, environmentLeases.companyId),
eq(adapterAuthSessions.environmentId, environmentLeases.environmentId),
sql`${adapterAuthSessions.id}::text = ${environmentLeases.metadata}->>${LOGIN_LEASE_SESSION_TAG_KEY}`,
or(
eq(adapterAuthSessions.providerLeaseId, environmentLeases.providerLeaseId),
and(
sql`${adapterAuthSessions.id}::text = ${environmentLeases.metadata}->>${LOGIN_LEASE_SESSION_TAG_KEY}`,
inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_ACTIVE_STATUSES]),
sql`greatest(${adapterAuthSessions.expiresAt}, case when ${adapterAuthSessions.status} = 'promoting' then ${adapterAuthSessions.promotionExpiresAt} end) > ${now.toISOString()}::timestamptz`,
),
@@ -0,0 +1,19 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { assertLoginEnvironmentTarget } from "./login-environment.js";
const fixtures = vi.hoisted(() => ({ environment: { id: "environment", driver: "plugin" }, companies: ["company"], agent: { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" } as Record<string, unknown> | undefined }));
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: async () => fixtures.environment, listBoundCompanyIds: async () => fixtures.companies }) }));
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => ({ defaultEnvironmentId: "environment" }) }) }));
vi.mock("./environment-selection.js", () => ({ assertEnvironmentSelectionForCompany: vi.fn(async () => {}) }));
const db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => fixtures.agent ? [fixtures.agent] : [] }) }) }) } as unknown as Db;
beforeEach(() => { fixtures.environment = { id: "environment", driver: "plugin" }; fixtures.companies = ["company"]; fixtures.agent = { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" }; });
describe("login environment transport target", () => {
it("permits a verifying saved agent on its own computer", async () => { await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).resolves.toEqual(fixtures.environment); });
it("rejects a provider without an existing-computer credential path", async () => { await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent", adapterType: "grok_local" })).rejects.toMatchObject({ status: 400 }); });
it("requires a saved target for existing-computer login", async () => { await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment" })).rejects.toMatchObject({ status: 400 }); });
it("rejects a foreign company environment", async () => { fixtures.companies = ["another-company"]; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).rejects.toMatchObject({ status: 403 }); });
it("does not expose a foreign or missing agent", async () => { fixtures.agent!.companyId = "another-company"; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).rejects.toMatchObject({ status: 404 }); });
it.each([{ defaultEnvironmentId: "another-environment" }, { lifecycleState: "pending" }, { adapterType: "codex_local" }])("rejects unavailable target binding %j", async change => { Object.assign(fixtures.agent!, change); await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).rejects.toMatchObject({ status: 400 }); });
it("supports the inherited environment", async () => { fixtures.agent!.defaultEnvironmentId = null; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).resolves.toEqual(fixtures.environment); });
it("retains agentless temporary sandbox login", async () => { fixtures.environment.driver = "sandbox"; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment" })).resolves.toEqual(fixtures.environment); });
});
+33
View File
@@ -0,0 +1,33 @@
import { agents, type Db } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { badRequest, forbidden, notFound } from "../errors.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
/** Validate a login's transport target without changing credential ownership. */
export async function assertLoginEnvironmentTarget(db: Db, input: {
companyId: string; environmentId: string; agentId?: string; adapterType?: string;
}) {
const environments = environmentService(db);
await assertEnvironmentSelectionForCompany(environments, input.companyId, input.environmentId, {
allowedDrivers: ["sandbox", "plugin"],
});
const environment = await environments.getById(input.environmentId);
if (!environment) throw notFound("Environment not found");
const companies = await environments.listBoundCompanyIds(environment.id);
if (companies.length && !companies.includes(input.companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
if (environment.driver !== "plugin") {
if (input.agentId) throw badRequest("A temporary sandbox login does not take an agent computer target.");
return environment;
}
if (input.adapterType && !["codex_local", "claude_local"].includes(input.adapterType)) throw badRequest("This provider login is not supported on agent computers.", { code: "login_adapter_unsupported" });
if (!input.agentId) throw badRequest("Select an agent computer for sign-in.", { code: "login_agent_required" });
const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))).limit(1);
if (!agent || agent.companyId !== input.companyId) throw notFound("Agent not found");
const inherited = agent.defaultEnvironmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
if (inherited !== environment.id || agent.adapterType !== "paperclip_runner" || !["ready", "verifying"].includes(agent.lifecycleState)) {
throw badRequest("The selected agent computer is not available for sign-in.", { code: "login_agent_unavailable" });
}
return environment;
}
@@ -133,6 +133,19 @@ export interface ReadyPluginWorkerRecovery {
timeoutMs?: number;
}
/** Shared admission/presentation check for allocation, recovery, cleanup and PTY transport. */
export function pluginSupportsLoginPty(
driver: Pick<PluginEnvironmentDriverDeclaration, "supportsLoginPty" | "creationOperationVersion">,
methods: readonly string[],
requireCredentialRead = false,
): boolean {
return driver.supportsLoginPty === true && driver.creationOperationVersion === 1 && [
"environmentAcquireLease", "environmentInspectLease", "environmentReleaseLease",
"loginPtyOpen", "loginPtyInput", "loginPtyStop", "loginPtyClose",
...(requireCredentialRead ? ["loginPtyReadCredential"] : []),
].every(method => methods.includes(method));
}
export interface ReadyPluginEnvironmentDriver {
pluginId: string;
pluginKey: string;
@@ -150,6 +163,7 @@ export interface ReadyPluginEnvironmentDriver {
* matches the acquisition guard, which also verifies the same methods live.
*/
reusableLeaseMethodsVerified: boolean;
supportsCodexLogin?: boolean;
supportsInteractiveSetup?: PluginEnvironmentDriverDeclaration["supportsInteractiveSetup"];
interactiveSetupConnectionTypes?: PluginEnvironmentDriverDeclaration["interactiveSetupConnectionTypes"];
supportsTemplateCapture?: PluginEnvironmentDriverDeclaration["supportsTemplateCapture"];
@@ -323,6 +337,7 @@ export async function resolvePluginSandboxProviderDriverById(input: {
}
export async function listReadyPluginEnvironmentDrivers(input: {
kind?: "sandbox_provider" | "environment_driver";
db: Db;
workerManager?: PluginWorkerManager;
recoverMissingWorker?: ReadyPluginWorkerRecovery;
@@ -374,7 +389,7 @@ export async function listReadyPluginEnvironmentDrivers(input: {
);
rows.push(
...(plugin.manifestJson.environmentDrivers ?? [])
.filter((driver) => driver.kind === "sandbox_provider")
.filter((driver) => driver.kind === (input.kind ?? "sandbox_provider"))
.map((driver) => ({
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
@@ -391,7 +406,8 @@ export async function listReadyPluginEnvironmentDrivers(input: {
templateRefKind: driver.templateRefKind,
templateConfigBinding: driver.templateConfigBinding,
supportsTemplateDelete: driver.supportsTemplateDelete,
supportsLoginPty: driver.supportsLoginPty,
supportsLoginPty: driver.kind === "environment_driver" ? pluginSupportsLoginPty(driver, [...workerMethods]) : driver.supportsLoginPty,
supportsCodexLogin: pluginSupportsLoginPty(driver, [...workerMethods], true),
})),
);
}
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { readPluginLoginCredential } from "./plugin-login-credential.js";
const getById = vi.hoisted(() => vi.fn());
vi.mock("./plugin-registry.js", () => ({ pluginRegistryService: () => ({ getById }) }));
const environment = { id: "environment", driver: "plugin" } as Environment;
const lease = { environmentId: "environment", companyId: "company", providerLeaseId: "login", metadata: { purpose: "login", loginCommandKey: "codex", pluginId: "plugin", driverKey: "computer" } } as unknown as EnvironmentLease;
const call = vi.fn();
const methods = ["loginPtyReadCredential"];
const workerManager = { call, getWorker: () => ({ supportedMethods: methods }) } as unknown as PluginWorkerManager;
beforeEach(() => { call.mockReset(); call.mockResolvedValue({ kind: "codex-auth-json", authJson: '{"fixture":true}' }); methods.splice(0, methods.length, "loginPtyReadCredential"); getById.mockResolvedValue({ id: "plugin", status: "ready", manifestJson: { environmentDrivers: [{ driverKey: "computer", supportsLoginPty: true }] } }); });
describe("plugin login fixed credential result", () => {
it("reads the exact bound lease through the optional RPC", async () => {
const bytes = await readPluginLoginCredential({ db: {} as Db, workerManager, environment, lease });
expect(bytes.toString()).toBe('{"fixture":true}');
expect(call).toHaveBeenCalledWith("plugin", "loginPtyReadCredential", { driverKey: "computer", companyId: "company", environmentId: "environment", providerLeaseId: "login", loginCommandKey: "codex" });
});
it("fails before calling a worker that lacks the optional read", async () => { methods.length = 0; await expect(readPluginLoginCredential({ db: {} as Db, workerManager, environment, lease })).rejects.toThrow("Login credential result unavailable"); expect(call).not.toHaveBeenCalled(); });
it.each([{ kind: "other", authJson: "private-provider-output" }, { kind: "codex-auth-json", authJson: "x".repeat(65537) }, { kind: "codex-auth-json", authJson: "" }])("rejects malformed or oversized private results", async result => { call.mockResolvedValue(result); await expect(readPluginLoginCredential({ db: {} as Db, workerManager, environment, lease })).rejects.toThrow(/^Login credential result unavailable$/); });
it("rejects a mismatched allocation scope before exporting", async () => { await expect(readPluginLoginCredential({ db: {} as Db, workerManager, environment: { ...environment, id: "other" }, lease })).rejects.toThrow(); expect(call).not.toHaveBeenCalled(); });
});
@@ -0,0 +1,28 @@
import type { Db } from "@paperclipai/db";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { pluginRegistryService } from "./plugin-registry.js";
const MAX_LOGIN_CREDENTIAL_BYTES = 64 * 1024;
/** Read only the fixed artifact of the exact durable login allocation. */
export async function readPluginLoginCredential(input: {
db: Db; workerManager?: PluginWorkerManager; environment: Environment; lease: EnvironmentLease;
}): Promise<Buffer> {
try {
const metadata = input.lease.metadata;
if (input.lease.environmentId !== input.environment.id || metadata?.purpose !== "login" || metadata.loginCommandKey !== "codex" ||
!input.lease.providerLeaseId || typeof metadata.pluginId !== "string" || typeof metadata.driverKey !== "string") throw new Error();
const plugin = await pluginRegistryService(input.db).getById(metadata.pluginId);
const driver = plugin?.manifestJson.environmentDrivers?.find(candidate => candidate.driverKey === metadata.driverKey);
if (!plugin || plugin.status !== "ready" || driver?.supportsLoginPty !== true ||
!input.workerManager?.getWorker(plugin.id)?.supportedMethods.includes("loginPtyReadCredential")) throw new Error();
const result = await input.workerManager.call(plugin.id, "loginPtyReadCredential", {
driverKey: metadata.driverKey, companyId: input.lease.companyId, environmentId: input.environment.id,
providerLeaseId: input.lease.providerLeaseId, loginCommandKey: "codex",
});
if (result?.kind !== "codex-auth-json" || typeof result.authJson !== "string" || !result.authJson.length || Buffer.byteLength(result.authJson) > MAX_LOGIN_CREDENTIAL_BYTES) throw new Error();
return Buffer.from(result.authJson, "utf8");
} catch {
throw new Error("Login credential result unavailable");
}
}
@@ -2,7 +2,7 @@ import { collectSecretRefPaths, isUuidSecretRef, parseSecretRefBindingObject, re
import { createHash, randomUUID } from "node:crypto";
import { and, eq, lte, or, sql } from "drizzle-orm";
import { currentControllerProcess } from "./controller-process-identity.js";
import { companies, environments, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { companies, environments, heartbeatRuns, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { readEnvironmentCreationCleanupError, type PluginEnvironmentLease, type PluginEnvironmentLeaseObservation } from "@paperclipai/plugin-sdk";
import { assertEnvironmentCompanyBinding, environmentService } from "./environments.js";
import { ENVIRONMENT_CLEANUP_RETRY_MS, recoverableSandboxCreationCondition } from "./environment-cleanup-state.js";
@@ -28,6 +28,11 @@ export function recoverableTaskCreation(operation: Pick<SandboxCreationOperation
metadata.providerMetadata !== null && typeof metadata.providerMetadata === "object" && !Array.isArray(metadata.providerMetadata);
}
/** Plugin allocations can return their exact creation lease after a lost reply. */
export function recoverablePluginCreation(operation: Pick<SandboxCreationOperation, "metadata">): boolean {
return recoverableTaskCreation(operation) || (operation.metadata.driver === "plugin" && operation.metadata.purpose === "login");
}
type CreationOwner = {
companyId: string;
environmentId: string;
@@ -72,7 +77,7 @@ export async function createOwnedSandbox(
id, companyId: owner.companyId, environmentId: owner.environmentId, originalEnvironmentId: owner.environmentId,
runId, requestKey: owner.requestKey ?? "run", issueId: owner.issueId, executionWorkspaceId: owner.executionWorkspaceId,
provider: owner.provider, pluginId: owner.pluginId, driverKey: owner.driverKey,
config: owner.config, metadata: { ...owner.metadata, creationCredentialHashes: credentialHashes,
config: owner.config, metadata: { ...owner.metadata, creationHeartbeatRunId: owner.heartbeatRunId ?? null, creationCredentialHashes: credentialHashes,
creationController, creationSettleAt: settleAt.toISOString() },
nextAttemptAt: settleAt,
}).onConflictDoNothing().returning();
@@ -97,11 +102,11 @@ export async function createOwnedSandbox(
crashPoint("sandbox.create.after_provider_before_lease");
return { ...lease, metadata: { ...lease.metadata, creationOperationId: id } };
} catch (error) {
if (recoverableTaskCreation(operation)) {
if (recoverablePluginCreation(operation)) {
try {
await writeEnvironmentCleanup(db, tx => tx.update(sandboxCreationOperations).set({ nextAttemptAt: new Date(), updatedAt: new Date() })
.where(and(eq(sandboxCreationOperations.id, id), recoverableSandboxCreationCondition())),
{ companyId: operation.companyId, runId: operation.runId });
{ companyId: operation.companyId, runId: owner.heartbeatRunId ?? undefined });
} catch {
logger.warn({ errorKind: "task_creation_recovery_wakeup_failed", operationId: id, companyId: operation.companyId },
"Task creation recovery retains its saved deadline");
@@ -147,15 +152,28 @@ export async function queueSandboxCreationCleanup(db: Db, input: {
? operation.metadata.runnerTaskCreationContextId : null;
if (operation.state !== "pending" && !(operation.state === "needs_reconciliation" && contextId &&
operation.metadata.creationProviderLeaseId === input.providerLeaseId)) throw new Error("Sandbox creation cleanup ownership changed");
await notifyEnvironmentCleanup(tx, { companyId: operation.companyId, runId: operation.runId });
// The non-null runId also correlates agentless creation attempts. Only the
// original optional heartbeat reference belongs in the lease's foreign key.
// Older task intents predate that explicit field; retain their relationship
// only when an actual same-company heartbeat row still exists.
const heartbeatReference = Object.hasOwn(operation.metadata, "creationHeartbeatRunId")
? operation.metadata.creationHeartbeatRunId : operation.runId;
const [heartbeat] = typeof heartbeatReference === "string"
? await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, heartbeatReference), eq(heartbeatRuns.companyId, operation.companyId),
)).for("key share")
: [];
const heartbeatRunId = heartbeat?.id ?? null;
await notifyEnvironmentCleanup(tx, { companyId: operation.companyId, runId: heartbeatRunId ?? undefined });
const lease = await environmentService(tx as unknown as Db).insertPendingCleanupLease({
companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
heartbeatRunId: operation.runId, issueId: operation.issueId, executionWorkspaceId: operation.executionWorkspaceId,
heartbeatRunId, issueId: operation.issueId, executionWorkspaceId: operation.executionWorkspaceId,
provider: operation.provider, providerLeaseId: input.providerLeaseId,
metadata: { ...operation.metadata, creationOperationId: operation.id, environmentId: operation.originalEnvironmentId,
providerMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {}, runnerTaskRetiring: true,
providerMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {},
...(operation.metadata.runnerTaskLease === true ? { runnerTaskRetiring: true } : {}),
...(contextId ? { runnerTaskStartupCleanup: { contextId, requiresPreparationStop: false } } : {}) },
failureReason: "interrupted_task_creation",
failureReason: operation.metadata.purpose === "login" ? "interrupted_login_creation" : "interrupted_task_creation",
});
await tx.update(sandboxCreationOperations).set({ state: "cleanup_pending", leaseId: lease.id, updatedAt: new Date(),
metadata: { ...operation.metadata, creationProviderLeaseId: input.providerLeaseId,
@@ -189,11 +207,14 @@ export async function recoverSandboxCreations(db: Db,
}
const [existingCompany] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, candidate.companyId)).limit(1);
const observation = existingCompany ? await inspect(candidate) : null;
if (existingCompany && recoverableTaskCreation(candidate) && observation?.creationLease) {
if (existingCompany && recoverablePluginCreation(candidate) && observation?.creationLease) {
const lease = observation.creationLease;
// The inspect call is scoped to this creation operation. Providers may
// assign a different resource ID; require the receipt/envelope to agree,
// then let the durable resource record reject any changed identity.
if (typeof lease.providerLeaseId !== "string" || lease.providerLeaseId.length === 0 || lease.providerLeaseId !== observation.providerLeaseId ||
!lease.metadata || typeof lease.metadata !== "object" || Array.isArray(lease.metadata)) {
throw new Error("Task creation recovery returned foreign ownership");
throw new Error("Plugin allocation creation recovery returned foreign ownership");
}
const identity = { id: candidate.id, companyId: candidate.companyId, environmentId: candidate.originalEnvironmentId,
providerLeaseId: lease.providerLeaseId, providerMetadata: lease.metadata };
@@ -202,7 +223,14 @@ export async function recoverSandboxCreations(db: Db,
await onTaskRecovered?.(candidate);
continue;
}
if (existingCompany && recoverableTaskCreation(candidate)) throw new Error("Task creation identity is not available yet");
if (existingCompany && candidate.metadata.purpose === "login" && observation?.state === "destroyed" && observation.providerLeaseId === candidate.id) {
const settledAt = Date.parse(typeof candidate.metadata.creationSettleAt === "string" ? candidate.metadata.creationSettleAt : "");
if (!Number.isFinite(settledAt) || Date.now() < settledAt) throw new Error("Login allocation creation has not settled yet");
await writeEnvironmentCleanup(db, tx => tx.update(sandboxCreationOperations).set({ state: "resolved", updatedAt: new Date() })
.where(and(eq(sandboxCreationOperations.id, candidate.id), eq(sandboxCreationOperations.state, "pending"))), { companyId: candidate.companyId });
continue;
}
if (existingCompany && recoverablePluginCreation(candidate)) throw new Error("Plugin allocation creation identity is not available yet");
const cleanup = observation?.creationCleanup ? readEnvironmentCreationCleanupError({ data: {
schema: "paperclip/environment-creation-cleanup/v1", cleanup: observation.creationCleanup,
} }) : null;
@@ -66,6 +66,7 @@ export function createProductionSetupTokenReaper(
): SetupTokenReaper {
const environments = environmentService(deps.db);
const sandbox = createProductionSetupTokenSandboxProvider({
db: deps.db,
environments,
environmentRuntime: deps.environmentRuntime,
log: deps.log,
@@ -771,6 +771,11 @@ describe("SetupTokenSessionService.cancelByScope", () => {
});
describe("SetupTokenSessionService.findActiveByScope", () => {
it("returns the executing computer separately from account ownership", async () => {
const { service } = buildService();
await service.start({ ...OWNER_SCOPE, agentId: "computer-a" });
expect((await service.findActiveByScope(OWNER_SCOPE))?.agentId).toBe("computer-a");
});
it("finds the caller's live active session with no session id", async () => {
const { service } = buildService();
const { sessionId } = await service.start(OWNER_SCOPE);
@@ -37,6 +37,7 @@ import type { ConnectionManagementContext } from "./connection-management.js";
import { randomBytes } from "node:crypto";
import { and, eq, gt, inArray, isNotNull, isNull, lte, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { environmentService } from "./environments.js";
import { adapterAuthSessions } from "@paperclipai/db";
import type { AgentAdapterType } from "@paperclipai/shared";
@@ -112,6 +113,7 @@ export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] =
* per environment.
*/
export interface SetupTokenSessionScope {
agentId?: string;
managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
@@ -255,6 +257,7 @@ export interface SetupTokenCleanupIdentity {
* already consumed. The store persists no secret.
*/
export interface SetupTokenCleanupStore {
getLoginAgentId?(leaseId: string, companyId: string): Promise<string | undefined>;
record(record: SetupTokenCleanupRecord): Promise<void>;
/**
* Marks the state only when the full owner scope and the session id match. The
@@ -690,6 +693,7 @@ export interface SetupTokenPromptView {
* response returns the login URL through the confidential transport guard.
*/
export interface SetupTokenSessionDescriptor {
agentId?: string;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
sessionId: string;
state: SetupTokenSessionState;
@@ -1124,6 +1128,7 @@ export class SetupTokenSessionService {
sessionId: session.id,
state: session.state,
environmentId: session.scope.environmentId,
...(session.scope.agentId ? { agentId: session.scope.agentId } : {}),
deadline: session.deadline,
loginUrl: session.loginUrl,
...(session.scope.aiConnection ? { aiConnection: session.scope.aiConnection } : {}),
@@ -1199,7 +1204,9 @@ export class SetupTokenSessionService {
}
const durable = await this.store.findActiveDurable(key, this.now());
if (!durable) return null;
const agentId = await this.store.getLoginAgentId?.(durable.leaseId, key.companyId);
return {
...(agentId ? { agentId } : {}),
sessionId: durable.sessionId,
...(durable.aiConnection ? { aiConnection: durable.aiConnection } : {}),
state: durable.state,
@@ -1546,6 +1553,10 @@ export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
const scopeMatch = setupTokenScopeMatch;
return {
async getLoginAgentId(leaseId, companyId) {
const lease = await environmentService(db).getLeaseById(leaseId);
return lease?.companyId === companyId && lease.metadata?.purpose === "login" ? (typeof lease.metadata.agentId === "string" ? lease.metadata.agentId : undefined) : undefined;
},
async record(record): Promise<void> {
// The database generates `id`. The store fills `public_session_id` with the
// service session id, which the service builds from a CSPRNG at start.
@@ -1,3 +1,4 @@
import { assertLoginEnvironmentTarget } from "./login-environment.js";
// The production transport binding for the Claude setup-token login session.
//
// The session service (`setup-token-session.ts`) drives the login flow over
@@ -36,7 +37,7 @@ import {
import { secretService } from "./secrets.js";
import type { environmentService } from "./environments.js";
import type { environmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { buildLoginLeaseAcquireArgs, loginLeaseBoundsDeadline } from "./adapter-login-lease.js";
import type { Db } from "@paperclipai/db";
import {
createLoginPtyTransport,
@@ -352,6 +353,7 @@ export function buildSetupTokenLoginTransport(
/** The dependencies the production sandbox provider needs. */
export interface ProductionSetupTokenSandboxProviderDeps {
db?: Db;
environments: Pick<ReturnType<typeof environmentService>, "getById" | "getLeaseById" | "releaseLease">;
environmentRuntime: Pick<ReturnType<typeof environmentRuntimeService>, "acquireRunLease" | "getDriver">;
/**
@@ -395,7 +397,9 @@ export function createProductionSetupTokenSandboxProvider(
return {
async acquire({ scope, deadline }) {
const environment = await deps.environments.getById(scope.environmentId);
const environment = deps.db
? await assertLoginEnvironmentTarget(deps.db, scope)
: await deps.environments.getById(scope.environmentId);
if (!environment) {
log("[paperclip] Setup-token login: the selected environment is not found.");
return failClosed();
@@ -418,14 +422,15 @@ export function createProductionSetupTokenSandboxProvider(
const leaseRecord = await deps.environmentRuntime.acquireRunLease(
buildLoginLeaseAcquireArgs({
browserLogin: true,
metadata: {
companyId: scope.companyId,
environment,
adapterType: scope.adapterType,
},
// The setup-token login is company-and-environment scoped. It carries
// no target agent, so the lease binds no agent.
targetAgentId: null,
// The optional agent selects an existing computer; credential ownership
// remains with the company and user.
targetAgentId: scope.agentId,
// Re-check the environment company binding inside the lease insert
// transaction. The route guard ran earlier, so a managed
// reconciliation can bind this sandbox to another company between the
@@ -449,9 +454,7 @@ export function createProductionSetupTokenSandboxProvider(
// the deadline, the server cannot guarantee the hard stop. Release the
// remote lease and fail closed, so the login holds no lease and leaves no
// durable cleanup row.
const expiresAt = leaseRecord.lease.expiresAt;
const expiresAtMs = expiresAt instanceof Date ? expiresAt.getTime() : Number.NaN;
if (!Number.isFinite(expiresAtMs) || expiresAtMs > deadline) {
if (!loginLeaseBoundsDeadline(leaseRecord.lease.expiresAt, deadline)) {
await this.release(leaseId);
log("[paperclip] Setup-token login: the acquired lease expiry does not bound the session deadline.");
return failClosed();
@@ -552,6 +555,7 @@ export interface LoginPtyWorkerManagerLike {
providerLeaseId: string;
loginCommandKey: LoginCommandKey;
sessionHome: string;
expiresAt?: string;
},
): Promise<LoginPtySession>;
}
@@ -561,6 +565,7 @@ export interface LoginPtyLeaseLookup {
getLeaseById(leaseId: string): Promise<
| {
providerLeaseId: string | null;
expiresAt?: Date | null;
metadata: Record<string, unknown> | null | undefined;
}
| null
@@ -610,7 +615,7 @@ export function createWorkerBoundLoginPtyOpener(
: {};
const pluginId = readLeaseMetaString(metadata.pluginId);
const driverKey =
readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
readLeaseMetaString(metadata.driverKey) ?? readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
if (!providerLeaseId || !pluginId || !driverKey) {
log("[paperclip] Setup-token login: the lease carries no sandbox worker binding.");
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
@@ -638,6 +643,7 @@ export function createWorkerBoundLoginPtyOpener(
providerLeaseId,
loginCommandKey,
sessionHome,
expiresAt: lease?.expiresAt?.toISOString(),
});
};
};
+2 -2
View File
@@ -329,7 +329,7 @@ export const agentsApi = {
startAdapterAuthLogin: (
companyId: string,
type: string,
data: { environmentId: string; ttlSeconds?: number; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
data: { environmentId: string; agentId?: string; ttlSeconds?: number; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
) =>
api.post<AdapterAuthSessionResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
@@ -372,7 +372,7 @@ export const agentsApi = {
),
startClaudeSetupTokenLogin: (
companyId: string,
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
data: { environmentId: string; agentId?: string; overwrite?: ClaudeSetupTokenOverwrite; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
) =>
api.post<ClaudeSetupTokenSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
@@ -2487,6 +2487,21 @@ describe("AgentConfigForm environment selector", () => {
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
});
it.each(["codex_local", "claude_local"])("does not resume %s on a different sign-in computer", async (adapterType) => {
const active = { sessionId: "computer-a-login", agentId: "computer-a", environmentId: "plugin-env", status: "waiting_for_user", prompt: null };
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue(active);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue(active);
const container = document.createElement("div"); document.body.appendChild(container);
const root = createRoot(container); roots.push(root);
await act(async () => renderFormView(root, createFormQueryClient(),
<AdapterLoginPanel requiresAgent agentId="computer-b" companyId="company-1" adapterType={adapterType} environmentId="plugin-env" chrome="onboarding" autoStart />));
await flushUntil(() => container.textContent?.includes("Another sign-in attempt") ?? false);
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.getAdapterAuthLoginStatus).not.toHaveBeenCalled();
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).not.toHaveBeenCalled();
});
it("recovers a previous-environment sign-in only after explicit successful cancellation", async () => {
const intent = { provider: "xai", method: "subscription", name: "My Grok subscription", ownership: "personal", agentIds: [], allAgents: true } as const;
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValueOnce({
+24 -19
View File
@@ -1,3 +1,5 @@
import { AgentLoginTarget } from "./AgentLoginTarget";
import { environmentSupportsLoginPty } from "@paperclipai/shared";
import { useAdapterLoginCancellation } from "./useAdapterLoginCancellation";
import { ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS, isAccountBoundAcpxAgent } from "@/lib/native-model-catalog";
import { useConnectionModels } from "./ai-connections/useConnectionModels";
@@ -1326,18 +1328,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// advertises the login pseudo-terminal capability. Read the capability for the
// effective environment provider. The form reads the adapter login capability,
// not the adapter name: every adapter login gates on this provider capability.
const effectiveLoginProvider =
typeof effectiveLoginEnvironment?.config?.provider === "string"
? effectiveLoginEnvironment.config.provider
: null;
const providerSupportsLoginPty =
effectiveLoginProvider != null &&
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
const providerSupportsLoginPty = environmentSupportsLoginPty(effectiveLoginEnvironment, environmentCapabilities, isRunnerHarness ? harnessType : adapterType);
const loginNeedsPty = adapterCaps.login != null;
const showAdapterLogin =
(isCreate || !((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection)) &&
adapterSupportsSandboxLogin &&
effectiveLoginEnvironment?.driver === "sandbox" &&
(effectiveLoginEnvironment?.driver === "sandbox" || effectiveLoginEnvironment?.driver === "plugin") &&
Boolean(effectiveLoginEnvironmentId) &&
Boolean(selectedCompanyId) &&
Boolean(authMissingCheck) &&
@@ -1917,6 +1913,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showInlineAdapterTestEnvironmentFeedback && showAdapterLogin && (
<AdapterLoginPanel
requiresAgent={effectiveLoginEnvironment?.driver === "plugin"}
agentId={!isCreate && effectiveLoginEnvironment?.driver === "plugin" ? props.agent.id : undefined}
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={isRunnerHarness ? harnessType : adapterType}
@@ -2498,6 +2496,7 @@ function AdapterLoginTerminalState({
// environment for one company. A parent that lifts the test feedback renders
// the panel from this descriptor.
export type AdapterLoginDescriptor = {
agentId?: string;
companyId: string;
adapterType: string;
environmentId: string;
@@ -2520,6 +2519,7 @@ export type AdapterLoginDescriptor = {
// correctly, and the first thing to rot would have been the timeout and
// cleanup paths, which are the ones nobody exercises by hand.
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
requiresAgent?: boolean;
active?: boolean;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
onStored?: (storedSessionId: string) => void;
@@ -2585,11 +2585,13 @@ function adapterLoginTitle(adapterType: string): string {
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
const getCapabilities = useAdapterCapabilities();
const panelMode = getCapabilities(props.adapterType).login?.panelMode;
if (panelMode === "submitted_browser_code") {
return <SubmittedBrowserCodeLoginPanel {...props} />;
}
return <DisplayedCodeLoginPanel {...props} />;
const Panel = getCapabilities(props.adapterType).login?.panelMode === "submitted_browser_code"
? SubmittedBrowserCodeLoginPanel : DisplayedCodeLoginPanel;
return props.requiresAgent
? <AgentLoginTarget companyId={props.companyId} environmentId={props.environmentId} agentId={props.agentId}>
{agentId => <Panel key={agentId} {...props} agentId={agentId} />}
</AgentLoginTarget>
: <Panel {...props} agentId={undefined} />;
}
class AdapterLoginConflictError extends Error {
@@ -2603,6 +2605,7 @@ function DisplayedCodeLoginPanel({
companyId,
adapterType,
environmentId,
agentId,
autoStart,
onConnected,
onAccountBinding,
@@ -2636,7 +2639,7 @@ function DisplayedCodeLoginPanel({
const resumedRef = useRef(false);
const startLogin = useMutation({
mutationFn: () => cancellation.accept(() => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })),
mutationFn: () => cancellation.accept(() => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, ...(agentId ? { agentId } : {}), ...(aiConnection ? { aiConnection } : {}) })),
onSuccess: (session) => {
if (!session || !cancellation.isActive()) return;
resumedRef.current = false;
@@ -2675,12 +2678,12 @@ function DisplayedCodeLoginPanel({
// browser rediscovers its own session after a reload with no local state. A
// 404 means no active session for the caller.
const activeSessionQuery = useQuery({
queryKey: ["adapter-login-active-session", companyId, adapterType],
queryKey: ["adapter-login-active-session", companyId, adapterType, environmentId, agentId],
queryFn: async ({ signal }) => {
try {
const active = await cancellation.resume(() => agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { signal }));
if (!active) return null;
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId);
if (active.agentId !== agentId || (aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId);
return active;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
@@ -3143,6 +3146,7 @@ function SubmittedBrowserCodeLoginPanel({
companyId,
adapterType,
environmentId,
agentId,
onStored,
onApplyStored,
autoStart,
@@ -3241,7 +3245,8 @@ function SubmittedBrowserCodeLoginPanel({
mutationFn: () => cancellation.accept(() =>
agentsApi.startClaudeSetupTokenLogin(companyId, {
environmentId,
aiConnection,
...(agentId ? { agentId } : {}),
...(aiConnection ? { aiConnection } : {}),
// When the owner already has a stored token, the login rotates it under
// the captured version, so a replacement login never conflicts with an
// existing value. Without a stored token the login is a first write.
@@ -3318,12 +3323,12 @@ function SubmittedBrowserCodeLoginPanel({
// session id, so the browser rediscovers its own session after a reload
// with no local state. A 404 means no active session for the caller.
const activeSessionQuery = useQuery({
queryKey: ["claude-setup-token-active-session", companyId],
queryKey: ["claude-setup-token-active-session", companyId, environmentId, agentId],
queryFn: async ({ signal }) => {
try {
const active = await cancellation.resume(() => agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { signal }));
if (!active) return null;
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
if (active.agentId !== agentId || (aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
return active;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { AgentLoginTarget } from "./AgentLoginTarget";
const mocks = vi.hoisted(() => ({ list: vi.fn(), settings: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: mocks.list } }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { get: mocks.settings } }));
let root: Root, container: HTMLDivElement, client: QueryClient;
async function settle() { for (let i = 0; i < 5; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } }
beforeEach(() => {
vi.clearAllMocks();
mocks.settings.mockResolvedValue({ defaultEnvironmentId: "environment" });
mocks.list.mockResolvedValue([
{ id: "eligible", name: "Setup computer", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: null },
{ id: "foreign-env", name: "Another computer", adapterType: "paperclip_runner", lifecycleState: "ready", defaultEnvironmentId: "another" },
{ id: "pending", name: "Pending computer", adapterType: "paperclip_runner", lifecycleState: "pending", defaultEnvironmentId: "environment" },
]);
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
});
afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); });
function mount(agentId?: string) { flushSync(() => root.render(<QueryClientProvider client={client}><AgentLoginTarget companyId="company" environmentId="environment" agentId={agentId}>{target => <div data-target={target}>Login ready</div>}</AgentLoginTarget></QueryClientProvider>)); }
it("uses a contextual saved agent without listing unrelated computers", async () => { mount("context-agent"); await settle(); expect(container.querySelector('[data-target="context-agent"]')).not.toBeNull(); expect(mocks.list).not.toHaveBeenCalled(); });
it("waits for an explicit eligible transport target before starting login", async () => {
mount(); await settle(); expect(container.querySelector("[data-target]")).toBeNull();
const trigger = container.querySelector('[role="combobox"]')!;
flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); await settle();
const options = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
expect(options.map(option => option.textContent)).toEqual(["Setup computer"]);
flushSync(() => options[0].click()); await settle();
expect(container.querySelector('[data-target="eligible"]')).not.toBeNull();
});
it("keeps selection work linear across a company-sized agent list", async () => {
let reads = 0;
const agents = Array.from({ length: 600 }, (_, index) => ({
get id() { if (++reads > 50000) throw new Error("Agent selection exceeded its linear work bound"); return `computer-${index}`; },
name: `Computer ${index}`, adapterType: "paperclip_runner", lifecycleState: "ready", defaultEnvironmentId: "environment",
}));
mocks.list.mockResolvedValue(agents);
mount(); await settle();
flushSync(() => container.querySelector('[role="combobox"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const options = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
expect(options).toHaveLength(600);
flushSync(() => options[599].click()); await settle();
expect(container.querySelector('[data-target="computer-599"]')).not.toBeNull();
expect(reads).toBeLessThan(50000);
});
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
/** The sign-in computer is independent of the account's ownership and grants. */
export function AgentLoginTarget({ companyId, environmentId, agentId, children }: {
companyId: string; environmentId: string; agentId?: string;
children: (targetAgentId: string) => ReactNode;
}) {
const [selected, setSelected] = useState("");
const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: !agentId });
const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get, enabled: !agentId });
const views = [useQueryView(agents), useQueryView(settings)];
if (agentId) return children(agentId);
if (views.some(view => view.kind === "loading" || view.kind === "reconnecting")) return <p role="status" className="text-sm text-muted-foreground">Finding sign-in computers…</p>;
const error = views.find(view => view.kind === "error");
if (error) return <QueryErrorState size="inline" error={error.error} action="load sign-in computers" onRetry={error.retry} retrying={error.isFetching} />;
if (!agents.data || !settings.data) return null;
const eligible = agents.data.filter(agent => agent.adapterType === "paperclip_runner" &&
(agent.lifecycleState === "ready" || agent.lifecycleState === "verifying") &&
(agent.defaultEnvironmentId ?? settings.data.defaultEnvironmentId) === environmentId);
const target = eligible.find(agent => agent.id === selected);
return <div className="space-y-4">
<Select value={target?.id ?? ""} onValueChange={setSelected}>
<SelectTrigger aria-label="Sign-in computer"><SelectValue placeholder="Choose a sign-in computer" /></SelectTrigger>
<SelectContent>{eligible.map(agent => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}</SelectContent>
</Select>
{!eligible.length && <p className="text-sm text-muted-foreground">An agent computer must finish setup before you can sign in.</p>}
{target && children(target.id)}
</div>;
}
@@ -1,3 +1,4 @@
import { environmentSupportsLoginPty } from "@paperclipai/shared";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type AiProvider, type AiAuthMethod, type AiConnectionLoginIntent } from "@paperclipai/shared";
@@ -28,6 +29,7 @@ type Props = {
agentIds: string[];
allAgents: boolean;
environmentId?: string;
loginAgentId?: string;
defaults?: ReactNode;
disabled?: boolean;
onComplete: (result: { connectionId: string; grantId: string; method: AiAuthMethod }) => void;
@@ -40,7 +42,7 @@ export function AiConnectionCredentialStep(props: Props) {
return <SubscriptionConnectionStep {...props} />;
}
function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel, defaults, disabled }: Props) {
function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, loginAgentId, onComplete, onCancel, defaults, disabled }: Props) {
const [name, setName] = useState(initialName);
const [chosenEnvironment, setChosenEnvironment] = useState<string>();
const client = useQueryClient();
@@ -69,9 +71,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
});
} catch (error) { environmentError = describeError(error).body; }
const loginEnvironments = (envs.data ?? []).filter((env) =>
env.status === "active" && (env.driver === "local" || (provider !== "google" && env.driver === "sandbox" &&
typeof env.config.provider === "string" &&
caps.data?.sandboxProviders?.[env.config.provider]?.supportsLoginPty === true)),
env.status === "active" && (env.driver === "local" || (provider !== "google" && environmentSupportsLoginPty(env, caps.data, provider === "openai" ? "codex_local" : provider === "anthropic" ? "claude_local" : "unsupported"))),
);
// Signing in may use a different environment from later agent execution.
// Prefer a supported login environment without changing any agent routing.
@@ -80,8 +80,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
environmentId = loginEnvironments[0]?.id ?? null;
}
const environment = envs.data?.find((env) => env.id === environmentId);
const sandboxProvider = typeof environment?.config.provider === "string" ? environment.config.provider : "";
const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
const canLogin = environmentSupportsLoginPty(environment, caps.data, provider === "openai" ? "codex_local" : provider === "anthropic" ? "claude_local" : "unsupported");
const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending) || views.some((view) => view.kind === "reconnecting");
const loadError = views.find((view) => view.kind === "error");
const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId };
@@ -100,6 +99,8 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
adapterType={provider === "google" ? "antigravity_runtime" : provider === "anthropic" ? "claude_local" : provider === "xai" ? "grok_local" : "codex_local"}
environmentId={environmentId}
canLogin={canLogin}
requiresAgent={environment?.driver === "plugin"}
agentId={environmentId === (suppliedEnvironmentId ?? settings.data?.defaultEnvironmentId) ? loginAgentId : undefined}
localEnvironment={environment?.driver === "local"}
onBack={onCancel}
onConnected={() => {}}
@@ -252,6 +252,7 @@ export function AiConnectionField({
agentIds={agentId ? [agentId] : []}
allAgents={allAgents}
environmentId={environmentId}
loginAgentId={agentId}
onCancel={() => setConnecting(false)}
onComplete={(result) => {
setSavedAccount(result);
@@ -40,6 +40,8 @@ export function AgentProviderConnection({
adapterType,
environmentId,
canLogin,
requiresAgent = false,
agentId,
localEnvironment = false,
onConnected,
onDraftChanged,
@@ -53,6 +55,8 @@ export function AgentProviderConnection({
adapterType: "claude_local" | "codex_local" | "grok_local" | "antigravity_runtime";
environmentId: string | null;
canLogin: boolean;
requiresAgent?: boolean;
agentId?: string;
localEnvironment?: boolean;
onConnected: (connection: ProviderConnection) => void;
onDraftChanged?: () => void;
@@ -368,6 +372,8 @@ export function AgentProviderConnection({
</OnboardingLoginCard>
) : needsLogin ? (
<AdapterLoginPanel
requiresAgent={requiresAgent}
agentId={agentId}
companyId={companyId}
adapterType={adapterType}
environmentId={environmentId}