Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -94,6 +95,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe
Layer.mergeAll(
serverConfigLayer.pipe(Layer.provide(PlatformTestLayer)),
serverSettingsLayer,
ServerSecretStore.layer.pipe(
Layer.provide(serverConfigLayer),
Layer.provide(PlatformTestLayer),
),
NodeServices.layer,
FetchHttpClient.layer,
OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -69,6 +70,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe
Layer.mergeAll(
serverConfigLayer.pipe(Layer.provide(PlatformTestLayer)),
serverSettingsLayer,
ServerSecretStore.layer.pipe(
Layer.provide(serverConfigLayer),
Layer.provide(PlatformTestLayer),
),
NodeServices.layer,
FetchHttpClient.layer,
OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -68,6 +69,10 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe
Layer.mergeAll(
serverConfigLayer.pipe(Layer.provide(PlatformTestLayer)),
serverSettingsLayer,
ServerSecretStore.layer.pipe(
Layer.provide(serverConfigLayer),
Layer.provide(PlatformTestLayer),
),
NodeServices.layer,
FetchHttpClient.layer,
OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)),
Expand Down
34 changes: 32 additions & 2 deletions apps/server/src/provider/CursorAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CursorAuthOptions {
readonly enabled: boolean;
readonly apiKey?: string;
readonly store: SdkCredentialStore;
readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>;
readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>;
readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>;
}
Expand Down Expand Up @@ -290,6 +291,11 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu
});

const controller: ProviderAuthController = {
credentialBinding: options.credentialBinding ?? {
owner: "t3",
key: `cursor:${options.instanceId}`,
},
isChangingCredentials: Effect.sync(() => operation !== "idle"),
start: (owner, stopSessions = Effect.void) =>
lock.withPermits(1)(
Effect.uninterruptible(
Expand Down Expand Up @@ -432,9 +438,33 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu
subscribe: (owner) =>
SubscriptionRef.changes(snapshot).pipe(
Stream.map((current) => {
if (current.owner === null || current.owner === owner) return current.state;
return {
const state = {
...current.state,
credentialOwner: "t3" as const,
methods: [
{
id: "browser",
name: "Sign in with Cursor",
description: null,
type: "agent" as const,
},
],
interaction:
current.state.phase === "waiting" &&
current.state.authorizationUrl &&
current.state.flowId
? {
type: "browser" as const,
id: current.state.flowId,
url: current.state.authorizationUrl,
requiresConsent: false,
}
: null,
};
if (current.owner === null || current.owner === owner) return state;
return {
...state,
interaction: null,
flowId: null,
authorizationUrl: null,
expiresAt: null,
Expand Down
141 changes: 141 additions & 0 deletions apps/server/src/provider/CursorCredentialStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ProviderInstanceId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Option from "effect/Option";

import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
import { makeCursorCredentialStore } from "./CursorCredentialStore.ts";

const makeSecrets = () => {
const data = new Map<string, Uint8Array>();
return {
data,
secrets: ServerSecretStore.of({
get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
set: (key, bytes) =>
Effect.sync(() => {
data.set(key, bytes);
}),
remove: (key) =>
Effect.sync(() => {
data.delete(key);
}),
create: () => Effect.die("unused"),
getOrCreateRandom: () => Effect.die("unused"),
}),
};
};

const legacyCredentials = {
version: 1 as const,
backendUrl: "https://api.cursor.com",
apiKey: "test-only-legacy-key",
createdAtMs: 100,
};
// The SDK's FileCredentialStore writes pretty-printed JSON.
const legacyFileText = `{
"version": 1,
"backendUrl": "https://api.cursor.com",
"apiKey": "test-only-legacy-key",
"createdAtMs": 100
}`;

it.effect.each([
{
name: "imports a legacy sign-in",
legacy: legacyFileText,
stored: undefined,
expected: "test-only-legacy-key",
},
{
name: "keeps a newer stored sign-in",
legacy: legacyFileText,
stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"),
expected: "test-only-current-key",
},
{
name: "replaces a damaged stored sign-in",
legacy: legacyFileText,
stored: "damaged",
expected: "test-only-legacy-key",
},
{
name: "discards a damaged legacy file",
legacy: "damaged",
stored: undefined,
expected: undefined,
},
])("$name and deletes the legacy file", ({ legacy, stored, expected }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json");
yield* fileSystem.writeFileString(legacyFile, legacy);
const { secrets } = makeSecrets();
const instanceId = ProviderInstanceId.make("personal");
if (stored !== undefined) {
const current = yield* makeCursorCredentialStore(instanceId).pipe(
Effect.provideService(ServerSecretStore, secrets),
);
yield* secrets.set(current.binding.key, new TextEncoder().encode(stored));
}
const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe(
Effect.provideService(ServerSecretStore, secrets),
);
assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected);
assert.isFalse(yield* fileSystem.exists(legacyFile));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect(
"restores SDK credentials in a new controller and keeps another account when signing out",
() =>
Effect.gen(function* () {
const data = new Map<string, Uint8Array>();
const secrets = ServerSecretStore.of({
get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
set: (key, bytes) =>
Effect.sync(() => {
data.set(key, bytes);
}),
remove: (key) =>
Effect.sync(() => {
data.delete(key);
}),
create: () => Effect.die("unused"),
getOrCreateRandom: () => Effect.die("unused"),
});
const makeStore = (id: string) =>
makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe(
Effect.provideService(ServerSecretStore, secrets),
);
const personal = yield* makeStore("personal");
const work = yield* makeStore("work");
const credentials = {
version: 1 as const,
backendUrl: "https://api.cursor.com",
apiKey: "test-only-key",
createdAtMs: 100,
apiKeyExpiresAtMs: 1000,
email: "test@example.com",
};
yield* Effect.tryPromise(() => personal.store.save(credentials));
yield* Effect.tryPromise(() =>
work.store.save({ ...credentials, apiKey: "test-only-work-key" }),
);
const restored = yield* makeStore("personal");
assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials);
data.set(personal.binding.key, new TextEncoder().encode("damaged credential"));
assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load()));
yield* Effect.tryPromise(() => restored.store.save(credentials));
yield* Effect.tryPromise(() => restored.store.clear());
assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load()));
assert.strictEqual(
(yield* Effect.tryPromise(() => work.store.load()))?.apiKey,
"test-only-work-key",
);
}).pipe(Effect.provide(NodeServices.layer)),
);
94 changes: 94 additions & 0 deletions apps/server/src/provider/CursorCredentialStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { SdkCredentialStore } from "@cursor/sdk";
import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";

const Credentials = Schema.fromJsonString(
Schema.Struct({
version: Schema.Literal(1),
backendUrl: Schema.String,
apiKey: Schema.String,
createdAtMs: Schema.Finite,
apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite),
email: Schema.optionalKey(Schema.String),
}),
);

const decodeCredentials = Schema.decodeUnknownEffect(Credentials);
const encodeCredentials = Schema.encodeEffect(Credentials);

/**
* The SDK owns the credential format; persistence uses the environment's secret store.
* `legacyFile` is the SDK file store earlier versions used; its sign-in moves into
* the secret store once and the file is deleted.
*/
export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* (
instanceId: ProviderInstanceId,
legacyFile?: string,
) {
const credentials = yield* ProviderCredentialStore.makeProviderCredentialStore(
"cursor",
instanceId,
);
if (legacyFile !== undefined) {
const fileSystem = yield* FileSystem.FileSystem;
yield* Effect.gen(function* () {
const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option);
if (Option.isNone(legacy)) return;
// A stored sign-in wins unless it is damaged; the SDK would ignore it anyway.
const stored = yield* credentials.get;
const storedIsValid =
Option.isSome(stored) &&
Option.isSome(
yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option),
);
if (
!storedIsValid &&
Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option))
) {
yield* credentials.set(new TextEncoder().encode(legacy.value));
}
yield* fileSystem.remove(legacyFile);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Could not migrate legacy Cursor credentials", error),
),
);
}
const store: SdkCredentialStore = {
load: () =>
Effect.runPromise(
credentials.get.pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed(undefined),
onSome: (bytes) =>
decodeCredentials(new TextDecoder().decode(bytes)).pipe(
Effect.orElseSucceed(() => undefined),
),
}),
),
),
),
save: (value) =>
Effect.runPromise(
encodeCredentials(value).pipe(
Effect.mapError(
() =>
new ProviderSetupError({
instanceId,
operation: "credentials",
detail: "Cursor returned an unsupported credential format.",
}),
),
Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))),
),
),
clear: () => Effect.runPromise(credentials.remove),
};
return { store, binding: credentials.binding };
});
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/CursorDriver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
Expand All @@ -20,9 +21,10 @@ import { layer as idAllocatorLayer } from "../../orchestration-v2/IdAllocator.ts
import { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts";
import { Cursor } from "../cursorSdk.ts";

const testLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-cursor-driver-copy-command-",
}).pipe(
const testLayer = ServerSecretStore.layer.pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-cursor-driver-copy-command-" }),
),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(idAllocatorLayer),
Layer.provideMerge(
Expand Down
Loading
Loading