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