diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index d9e1db4bfa7..d2243782769 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -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. diff --git a/packages/shared/src/environment-support.test.ts b/packages/shared/src/environment-support.test.ts index f0bc2520cd5..82a4239763f 100644 --- a/packages/shared/src/environment-support.test.ts +++ b/packages/shared/src/environment-support.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index 3a61f86a51b..16784cf2f07 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -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; adapters: AdapterEnvironmentSupport[]; drivers: Record; sandboxProviders: Record; @@ -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 } | 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; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9ce4d55a17f..869a8bb364a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2626,6 +2626,7 @@ export { export { adapterSupportsRemoteManagedEnvironments, getEnvironmentCapabilities, + environmentSupportsLoginPty, getAdapterEnvironmentSupport, isEnvironmentDriverSupportedForAdapter, isSandboxProviderSupportedForAdapter, diff --git a/packages/shared/src/types/agent.adapter-auth-session.test.ts b/packages/shared/src/types/agent.adapter-auth-session.test.ts index 2b6af7a2cf8..d651511d78c 100644 --- a/packages/shared/src/types/agent.adapter-auth-session.test.ts +++ b/packages/shared/src/types/agent.adapter-auth-session.test.ts @@ -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", () => { diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 08a3d3b7693..11f5dbdf5b4 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -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; diff --git a/packages/shared/src/validators/adapter-auth-session.ts b/packages/shared/src/validators/adapter-auth-session.ts index b98b6c4c70d..14cffbd7281 100644 --- a/packages/shared/src/validators/adapter-auth-session.ts +++ b/packages/shared/src/validators/adapter-auth-session.ts @@ -35,6 +35,7 @@ export type AdapterAuthSessionPrompt = z.infer; export const startAdapterAuthSessionRequestSchema = z.object({ + agentId: z.string().guid().optional(), aiConnection: aiConnectionLoginIntentSchema.optional(), environmentId: z.string().guid(), adapterType: z.enum(AGENT_ADAPTER_TYPES), diff --git a/packages/shared/src/validators/claude-setup-token-session.test.ts b/packages/shared/src/validators/claude-setup-token-session.test.ts index 2e672819bfb..9eca823f24a 100644 --- a/packages/shared/src/validators/claude-setup-token-session.test.ts +++ b/packages/shared/src/validators/claude-setup-token-session.test.ts @@ -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"); }); diff --git a/packages/shared/src/validators/claude-setup-token-session.ts b/packages/shared/src/validators/claude-setup-token-session.ts index 581303dd7af..97d25085c84 100644 --- a/packages/shared/src/validators/claude-setup-token-session.ts +++ b/packages/shared/src/validators/claude-setup-token-session.ts @@ -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(), diff --git a/server/src/__tests__/device-login-reaper.test.ts b/server/src/__tests__/device-login-reaper.test.ts index 28d7c0703f4..b77d0496d7d 100644 --- a/server/src/__tests__/device-login-reaper.test.ts +++ b/server/src/__tests__/device-login-reaper.test.ts @@ -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); }); diff --git a/server/src/__tests__/device-login-service.test.ts b/server/src/__tests__/device-login-service.test.ts index 3f98ed368d9..decd3560b68 100644 --- a/server/src/__tests__/device-login-service.test.ts +++ b/server/src/__tests__/device-login-service.test.ts @@ -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. diff --git a/server/src/__tests__/login-session-runtime.test.ts b/server/src/__tests__/login-session-runtime.test.ts index 532526a397e..c5069d1ba2d 100644 --- a/server/src/__tests__/login-session-runtime.test.ts +++ b/server/src/__tests__/login-session-runtime.test.ts @@ -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(fn: (tx: object) => Promise) { 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(); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 22830944788..53d74afb9cb 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1508,6 +1508,10 @@ describe("openapi routes", () => { expect( (responseSchemas.start.properties as Record).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).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. diff --git a/server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts b/server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts index d1716928e4b..3d531f03c90 100644 --- a/server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts +++ b/server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts @@ -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"); + }, + ); }); diff --git a/server/src/__tests__/sandbox-creation-operations.test.ts b/server/src/__tests__/sandbox-creation-operations.test.ts index 784f7983a7a..af31da1b260 100644 --- a/server/src/__tests__/sandbox-creation-operations.test.ts +++ b/server/src/__tests__/sandbox-creation-operations.test.ts @@ -25,7 +25,7 @@ describe("durable sandbox creation through the runtime entry point", () => { let database: Awaited> | undefined; let db: ReturnType; 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) | undefined; const resources = new Map(); 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); diff --git a/server/src/app.ts b/server/src/app.ts index 987ebd685a8..e72b0c04b97 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f2094fed275..055d152a447 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 { 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 { - 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 { + async function resolveProviderSupportsLoginPty(environmentId: string, requireCredentialRead = false): Promise { const environment = await environmentsSvc.getById(environmentId); const config = environment?.config && typeof environment.config === "object" ? (environment.config as Record) : {}; + 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 { - 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) => { diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 3260ae6c989..118bc6569d0 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -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) => { diff --git a/server/src/routes/setup-token-route.test.ts b/server/src/routes/setup-token-route.test.ts index b101fdd276a..1533fa31c3f 100644 --- a/server/src/routes/setup-token-route.test.ts +++ b/server/src/routes/setup-token-route.test.ts @@ -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([]); diff --git a/server/src/services/adapter-login-lease.test.ts b/server/src/services/adapter-login-lease.test.ts index 5a0d6984aa2..d9d0d2ab579 100644 --- a/server/src/services/adapter-login-lease.test.ts +++ b/server/src/services/adapter-login-lease.test.ts @@ -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" }, diff --git a/server/src/services/adapter-login-lease.ts b/server/src/services/adapter-login-lease.ts index 2a573abe0be..a689f9450a2 100644 --- a/server/src/services/adapter-login-lease.ts +++ b/server/src/services/adapter-login-lease.ts @@ -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[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, diff --git a/server/src/services/device-login-reaper.ts b/server/src/services/device-login-reaper.ts index f19151fb60c..a21ceb91116 100644 --- a/server/src/services/device-login-reaper.ts +++ b/server/src/services/device-login-reaper.ts @@ -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. diff --git a/server/src/services/device-login-service.ts b/server/src/services/device-login-service.ts index c5ee52a96ff..99d066892ec 100644 --- a/server/src/services/device-login-service.ts +++ b/server/src/services/device-login-service.ts @@ -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; insert(input: InsertAdapterAuthSessionInput): Promise; 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; /** The terminal sessions left in the internal `cleanup_pending` state. */ listCleanupPendingSessions(): Promise; - /** Every provider lease reference a session row still holds. */ - listLeaseReferences(): Promise; + /** The exact session/environment/provider references that rows still hold. */ + listLeaseReferences(): Promise; setStatus(input: SetAdapterAuthSessionStatusInput): Promise; /** A conditional status write. It returns true only when it changed one row. */ compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise; @@ -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 { 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; }): 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 { diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 2d5dd44d074..c401e326606 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -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 { + return readPluginLoginCredential({ db, workerManager: options.pluginWorkerManager, ...input }); + }, + async execute(input: EnvironmentDriverExecuteInput): Promise { const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment)); if (!driver.execute) { diff --git a/server/src/services/login-cleanup-state.ts b/server/src/services/login-cleanup-state.ts index 5129111a59f..df9ed22afa5 100644 --- a/server/src/services/login-cleanup-state.ts +++ b/server/src/services/login-cleanup-state.ts @@ -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`, ), diff --git a/server/src/services/login-environment.test.ts b/server/src/services/login-environment.test.ts new file mode 100644 index 00000000000..13aa8f91e89 --- /dev/null +++ b/server/src/services/login-environment.test.ts @@ -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 | 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); }); +}); diff --git a/server/src/services/login-environment.ts b/server/src/services/login-environment.ts new file mode 100644 index 00000000000..b36b9c389f8 --- /dev/null +++ b/server/src/services/login-environment.ts @@ -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; +} diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 88aa2a61726..a27edf1c9da 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -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, + 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), })), ); } diff --git a/server/src/services/plugin-login-credential.test.ts b/server/src/services/plugin-login-credential.test.ts new file mode 100644 index 00000000000..aca3916f3ef --- /dev/null +++ b/server/src/services/plugin-login-credential.test.ts @@ -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(); }); +}); diff --git a/server/src/services/plugin-login-credential.ts b/server/src/services/plugin-login-credential.ts new file mode 100644 index 00000000000..d84a45234ba --- /dev/null +++ b/server/src/services/plugin-login-credential.ts @@ -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 { + 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"); + } +} diff --git a/server/src/services/sandbox-creation-operations.ts b/server/src/services/sandbox-creation-operations.ts index bbc8369962e..4ab1d20048b 100644 --- a/server/src/services/sandbox-creation-operations.ts +++ b/server/src/services/sandbox-creation-operations.ts @@ -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): 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; diff --git a/server/src/services/setup-token-reaper.ts b/server/src/services/setup-token-reaper.ts index a1105eaea97..3be703b7c20 100644 --- a/server/src/services/setup-token-reaper.ts +++ b/server/src/services/setup-token-reaper.ts @@ -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, diff --git a/server/src/services/setup-token-session.test.ts b/server/src/services/setup-token-session.test.ts index 6e579b1945f..86956e89f11 100644 --- a/server/src/services/setup-token-session.test.ts +++ b/server/src/services/setup-token-session.test.ts @@ -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); diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts index a1c1ec9d072..6de393af020 100644 --- a/server/src/services/setup-token-session.ts +++ b/server/src/services/setup-token-session.ts @@ -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; record(record: SetupTokenCleanupRecord): Promise; /** * 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 { // The database generates `id`. The store fills `public_session_id` with the // service session id, which the service builds from a CSPRNG at start. diff --git a/server/src/services/setup-token-transport-binding.ts b/server/src/services/setup-token-transport-binding.ts index 13521fa5ee6..93dd55c89e0 100644 --- a/server/src/services/setup-token-transport-binding.ts +++ b/server/src/services/setup-token-transport-binding.ts @@ -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, "getById" | "getLeaseById" | "releaseLease">; environmentRuntime: Pick, "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; } @@ -561,6 +565,7 @@ export interface LoginPtyLeaseLookup { getLeaseById(leaseId: string): Promise< | { providerLeaseId: string | null; + expiresAt?: Date | null; metadata: Record | 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(), }); }; }; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index f192d65bc4c..b0fce9d8a8c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -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( `/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( `/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`, diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 9dd03313a6a..f530370a3a0 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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(), + )); + 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({ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 97dd7c52241..d218f433a2e 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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 | 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 && ( 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 ; - } - return ; + const Panel = getCapabilities(props.adapterType).login?.panelMode === "submitted_browser_code" + ? SubmittedBrowserCodeLoginPanel : DisplayedCodeLoginPanel; + return props.requiresAgent + ? + {agentId => } + + : ; } 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; diff --git a/ui/src/components/AgentLoginTarget.test.tsx b/ui/src/components/AgentLoginTarget.test.tsx new file mode 100644 index 00000000000..c14199f14d7 --- /dev/null +++ b/ui/src/components/AgentLoginTarget.test.tsx @@ -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({target =>
Login ready
}
)); } +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('[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('[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); +}); diff --git a/ui/src/components/AgentLoginTarget.tsx b/ui/src/components/AgentLoginTarget.tsx new file mode 100644 index 00000000000..d79d08312b2 --- /dev/null +++ b/ui/src/components/AgentLoginTarget.tsx @@ -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

Finding sign-in computers…

; + const error = views.find(view => view.kind === "error"); + if (error) return ; + 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
+ + {!eligible.length &&

An agent computer must finish setup before you can sign in.

} + {target && children(target.id)} +
; +} diff --git a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx index 1d10aac49b4..83a7af6a4d0 100644 --- a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx +++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx @@ -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 ; } -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(); 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={() => {}} diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index f81ad871bf6..eb0bbfbb7b7 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -252,6 +252,7 @@ export function AiConnectionField({ agentIds={agentId ? [agentId] : []} allAgents={allAgents} environmentId={environmentId} + loginAgentId={agentId} onCancel={() => setConnecting(false)} onComplete={(result) => { setSavedAccount(result); diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index 1d18464b597..ca599f51a0c 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -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({ ) : needsLogin ? (