pi-provider-cursor-ask 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-provider-cursor-ask",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone Cursor provider for focused Pi advisor and review workflows.",
|
|
5
|
+
"author": "zhcsyncer",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/zhcsyncer/pi-extensions.git",
|
|
10
|
+
"directory": "providers/pi-provider-cursor-ask"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/zhcsyncer/pi-extensions/tree/main/providers/pi-provider-cursor-ask#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/zhcsyncer/pi-extensions/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"pi-provider",
|
|
25
|
+
"cursor",
|
|
26
|
+
"oauth",
|
|
27
|
+
"advisor",
|
|
28
|
+
"review"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md",
|
|
33
|
+
"README.zh-CN.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"UPSTREAM_CHANGELOG.md",
|
|
36
|
+
"UPSTREAM_SOURCE.md",
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup --silent",
|
|
45
|
+
"prepare": "pnpm build",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "eslint src tests",
|
|
48
|
+
"lint:fix": "eslint src tests --fix",
|
|
49
|
+
"format": "prettier --write .",
|
|
50
|
+
"format:check": "prettier --check .",
|
|
51
|
+
"security-check": "tsx scripts/security-check.ts",
|
|
52
|
+
"proto:gen": "buf generate",
|
|
53
|
+
"proto:sync": "tsx scripts/proto-sync.ts",
|
|
54
|
+
"proto:check": "tsx scripts/proto-check.ts",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:coverage": "vitest run --coverage",
|
|
57
|
+
"test:watch": "vitest",
|
|
58
|
+
"test:legacy": "tsx scripts/test-model-routing.ts && tsx scripts/test-thinking-levels.ts && tsx scripts/test-usage.ts && tsx scripts/test-context-mode-normalize.ts && tsx scripts/test-cli-auth.ts",
|
|
59
|
+
"smoke:auth": "node --import tsx scripts/smoke-auth.mjs",
|
|
60
|
+
"smoke:models": "node --import tsx scripts/smoke-models.mjs",
|
|
61
|
+
"smoke:stream": "node --import tsx scripts/smoke-stream.mjs",
|
|
62
|
+
"smoke:wire": "node --import tsx scripts/smoke-wire.mjs",
|
|
63
|
+
"check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm security-check && pnpm proto:check && pnpm test && pnpm test:legacy && pnpm build"
|
|
64
|
+
},
|
|
65
|
+
"pi": {
|
|
66
|
+
"extensions": [
|
|
67
|
+
"./src/index.ts"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@bufbuild/protobuf": "2.13.0"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"@earendil-works/pi-ai": "*",
|
|
75
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
76
|
+
"@earendil-works/pi-tui": "*"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@bufbuild/buf": "1.72.0",
|
|
80
|
+
"@bufbuild/protoc-gen-es": "2.13.0",
|
|
81
|
+
"@earendil-works/pi-ai": "0.84.0",
|
|
82
|
+
"@earendil-works/pi-coding-agent": "0.84.0",
|
|
83
|
+
"@earendil-works/pi-tui": "0.84.0",
|
|
84
|
+
"@eslint/js": "^10.0.1",
|
|
85
|
+
"@types/node": "^26.1.1",
|
|
86
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
87
|
+
"eslint": "^10.7.0",
|
|
88
|
+
"eslint-config-prettier": "^10.1.8",
|
|
89
|
+
"globals": "^17.7.0",
|
|
90
|
+
"prettier": "^3.9.5",
|
|
91
|
+
"tsup": "^8.5.1",
|
|
92
|
+
"tsx": "^4.23.1",
|
|
93
|
+
"typescript": "^6.0.3",
|
|
94
|
+
"typescript-eslint": "^8.64.0",
|
|
95
|
+
"vitest": "^3.2.4"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
import { systemCredentialsAllowed } from "./consent.js";
|
|
9
|
+
import { getCursorAccessTokenFromEnv, getTokenExpiry, refreshCursorToken } from "./oauth.js";
|
|
10
|
+
import { isRefreshKnownBad, markRefreshFailed, markRefreshSucceeded } from "./refresh-guard.js";
|
|
11
|
+
import { CredentialSource } from "../types/enums.js";
|
|
12
|
+
|
|
13
|
+
export { CredentialSource };
|
|
14
|
+
|
|
15
|
+
export interface CursorTokenResult {
|
|
16
|
+
accessToken: string;
|
|
17
|
+
source: CredentialSource;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Raw token pair as stored by the Cursor CLI or IDE, before any validation. */
|
|
21
|
+
interface StoredTokens {
|
|
22
|
+
accessToken?: string;
|
|
23
|
+
refreshToken?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Windows account that owns this WSL session — never scan sibling profiles. */
|
|
27
|
+
export function windowsUsernameFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
28
|
+
const profile = env.USERPROFILE?.trim();
|
|
29
|
+
if (profile) {
|
|
30
|
+
const normalized = profile.replace(/\\/g, "/");
|
|
31
|
+
const match = /\/Users\/([^/]+)/i.exec(normalized);
|
|
32
|
+
const fromProfile = match?.[1]?.trim();
|
|
33
|
+
if (
|
|
34
|
+
fromProfile &&
|
|
35
|
+
fromProfile !== "Public" &&
|
|
36
|
+
fromProfile !== "Default" &&
|
|
37
|
+
!fromProfile.startsWith(".")
|
|
38
|
+
) {
|
|
39
|
+
return fromProfile;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const username = env.USERNAME?.trim();
|
|
43
|
+
if (username && username !== "Public" && username !== "Default" && !username.startsWith(".")) {
|
|
44
|
+
return username;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isUsable(token: string | undefined): token is string {
|
|
50
|
+
if (!token) return false;
|
|
51
|
+
try {
|
|
52
|
+
return Date.now() < getTokenExpiry(token);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Exchange a refresh token, honouring the failure back-off.
|
|
60
|
+
*
|
|
61
|
+
* Returns undefined without touching the network when this exact token failed
|
|
62
|
+
* recently — a stale Cursor CLI entry otherwise costs seconds on every launch.
|
|
63
|
+
*/
|
|
64
|
+
async function tryRefresh(refreshToken: string | undefined): Promise<string | undefined> {
|
|
65
|
+
if (!refreshToken || isRefreshKnownBad(refreshToken)) return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const refreshed = await refreshCursorToken(refreshToken);
|
|
68
|
+
markRefreshSucceeded(refreshToken);
|
|
69
|
+
return refreshed.access;
|
|
70
|
+
} catch {
|
|
71
|
+
markRefreshFailed(refreshToken);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Reads the raw Cursor CLI token pair from the macOS Keychain. */
|
|
77
|
+
async function readKeychainTokens(): Promise<StoredTokens> {
|
|
78
|
+
if (platform() !== "darwin") return {};
|
|
79
|
+
|
|
80
|
+
// Run both Keychain lookups concurrently — they are independent reads.
|
|
81
|
+
const [accessResult, refreshResult] = await Promise.allSettled([
|
|
82
|
+
execFileAsync(
|
|
83
|
+
"security",
|
|
84
|
+
["find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w"],
|
|
85
|
+
{ encoding: "utf8", timeout: 2000 },
|
|
86
|
+
),
|
|
87
|
+
execFileAsync(
|
|
88
|
+
"security",
|
|
89
|
+
["find-generic-password", "-s", "cursor-refresh-token", "-a", "cursor-user", "-w"],
|
|
90
|
+
{ encoding: "utf8", timeout: 2000 },
|
|
91
|
+
),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const tokens: StoredTokens = {};
|
|
95
|
+
if (accessResult.status === "fulfilled") {
|
|
96
|
+
const raw = accessResult.value.stdout.trim();
|
|
97
|
+
if (raw) tokens.accessToken = raw;
|
|
98
|
+
}
|
|
99
|
+
if (refreshResult.status === "fulfilled") {
|
|
100
|
+
const raw = refreshResult.value.stdout.trim();
|
|
101
|
+
if (raw) tokens.refreshToken = raw;
|
|
102
|
+
}
|
|
103
|
+
return tokens;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reads token from macOS Keychain (security CLI).
|
|
108
|
+
* Uses async execFile so Keychain reads don't block the event loop.
|
|
109
|
+
*/
|
|
110
|
+
export async function getCursorKeychainToken(): Promise<CursorTokenResult | undefined> {
|
|
111
|
+
const { accessToken, refreshToken } = await readKeychainTokens();
|
|
112
|
+
if (isUsable(accessToken)) return { accessToken, source: CredentialSource.CliKeychain };
|
|
113
|
+
const refreshed = await tryRefresh(refreshToken);
|
|
114
|
+
return refreshed
|
|
115
|
+
? { accessToken: refreshed, source: CredentialSource.CliKeychainRefresh }
|
|
116
|
+
: undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Cache the DatabaseSync constructor at module level so repeated vscdb lookups
|
|
120
|
+
// don't pay the dynamic-import cost each time.
|
|
121
|
+
let _databaseSyncCache:
|
|
122
|
+
| (new (
|
|
123
|
+
path: string,
|
|
124
|
+
options?: { readOnly?: boolean },
|
|
125
|
+
) => { prepare(sql: string): { get(): unknown }; close(): void })
|
|
126
|
+
| null
|
|
127
|
+
| undefined = undefined;
|
|
128
|
+
|
|
129
|
+
async function getDatabaseSync() {
|
|
130
|
+
if (_databaseSyncCache !== undefined) return _databaseSyncCache ?? undefined;
|
|
131
|
+
try {
|
|
132
|
+
const mod = await import("node:sqlite");
|
|
133
|
+
_databaseSyncCache = mod.DatabaseSync as unknown as typeof _databaseSyncCache;
|
|
134
|
+
return _databaseSyncCache ?? undefined;
|
|
135
|
+
} catch {
|
|
136
|
+
_databaseSyncCache = null;
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Reads the raw Cursor IDE token pair from state.vscdb. */
|
|
142
|
+
async function readVscdbTokens(): Promise<StoredTokens> {
|
|
143
|
+
const DatabaseSyncClass = await getDatabaseSync();
|
|
144
|
+
if (!DatabaseSyncClass) return {};
|
|
145
|
+
|
|
146
|
+
const dbPaths: string[] = [];
|
|
147
|
+
const home = homedir();
|
|
148
|
+
|
|
149
|
+
if (platform() === "darwin") {
|
|
150
|
+
dbPaths.push(join(home, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"));
|
|
151
|
+
} else if (platform() === "win32") {
|
|
152
|
+
if (process.env.APPDATA) {
|
|
153
|
+
dbPaths.push(join(process.env.APPDATA, "Cursor/User/globalStorage/state.vscdb"));
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// Linux / WSL
|
|
157
|
+
dbPaths.push(join(home, ".config/Cursor/User/globalStorage/state.vscdb"));
|
|
158
|
+
const wslHost = Boolean(
|
|
159
|
+
process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP || existsSync("/mnt/c/Users"),
|
|
160
|
+
);
|
|
161
|
+
if (wslHost) {
|
|
162
|
+
const windowsUser = windowsUsernameFromEnv(process.env);
|
|
163
|
+
if (windowsUser) {
|
|
164
|
+
dbPaths.push(
|
|
165
|
+
join(
|
|
166
|
+
"/mnt/c/Users",
|
|
167
|
+
windowsUser,
|
|
168
|
+
"AppData/Roaming/Cursor/User/globalStorage/state.vscdb",
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Prefer the first profile that yields a usable access token; otherwise keep
|
|
176
|
+
// the first refresh token seen so a single expired profile still has a path
|
|
177
|
+
// back to a live session.
|
|
178
|
+
const fallback: StoredTokens = {};
|
|
179
|
+
for (const dbPath of dbPaths) {
|
|
180
|
+
try {
|
|
181
|
+
const db = new DatabaseSyncClass(dbPath, { readOnly: true });
|
|
182
|
+
let accessRow: { value?: string } | undefined;
|
|
183
|
+
let refreshRow: { value?: string } | undefined;
|
|
184
|
+
try {
|
|
185
|
+
accessRow = db
|
|
186
|
+
.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'")
|
|
187
|
+
.get() as { value?: string } | undefined;
|
|
188
|
+
refreshRow = db
|
|
189
|
+
.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/refreshToken'")
|
|
190
|
+
.get() as { value?: string } | undefined;
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const accessToken = typeof accessRow?.value === "string" ? accessRow.value.trim() : undefined;
|
|
196
|
+
const refreshToken =
|
|
197
|
+
typeof refreshRow?.value === "string" ? refreshRow.value.trim() : undefined;
|
|
198
|
+
|
|
199
|
+
if (isUsable(accessToken)) return { accessToken, refreshToken };
|
|
200
|
+
if (!fallback.refreshToken && refreshToken) fallback.refreshToken = refreshToken;
|
|
201
|
+
if (!fallback.accessToken && accessToken) fallback.accessToken = accessToken;
|
|
202
|
+
} catch {
|
|
203
|
+
// Database missing or unreadable
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reads token from Cursor IDE state.vscdb.
|
|
211
|
+
*/
|
|
212
|
+
export async function getCursorVscdbToken(): Promise<CursorTokenResult | undefined> {
|
|
213
|
+
const { accessToken, refreshToken } = await readVscdbTokens();
|
|
214
|
+
if (isUsable(accessToken)) return { accessToken, source: CredentialSource.IdeVscdb };
|
|
215
|
+
const refreshed = await tryRefresh(refreshToken);
|
|
216
|
+
return refreshed
|
|
217
|
+
? { accessToken: refreshed, source: CredentialSource.IdeVscdbRefresh }
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Full credential resolution cascade:
|
|
223
|
+
* 1. CURSOR_ACCESS_TOKEN env var
|
|
224
|
+
* 2. macOS Keychain (Cursor CLI) — gated by system-credential consent
|
|
225
|
+
* 3. Cursor IDE state.vscdb — gated by system-credential consent
|
|
226
|
+
*
|
|
227
|
+
* Both system sources are read concurrently and every *local* token is checked
|
|
228
|
+
* before any network exchange is attempted. That ordering matters: a stale CLI
|
|
229
|
+
* keychain entry used to trigger a doomed ~2.6s refresh on the startup path
|
|
230
|
+
* even though the IDE database held a valid token two milliseconds away.
|
|
231
|
+
*
|
|
232
|
+
* Opt out of Keychain/vscdb scraping with PI_CURSOR_SYSTEM_CREDENTIALS=0.
|
|
233
|
+
*/
|
|
234
|
+
export async function resolveSystemCursorAccessToken(options?: {
|
|
235
|
+
forceRefresh?: boolean;
|
|
236
|
+
}): Promise<CursorTokenResult | undefined> {
|
|
237
|
+
const envToken = getCursorAccessTokenFromEnv();
|
|
238
|
+
if (envToken) {
|
|
239
|
+
// Env tokens cannot be refreshed here; still prefer them when present.
|
|
240
|
+
if (!options?.forceRefresh || isUsable(envToken)) {
|
|
241
|
+
return { accessToken: envToken, source: CredentialSource.Env };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!systemCredentialsAllowed()) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const [keychain, vscdb] = await Promise.all([readKeychainTokens(), readVscdbTokens()]);
|
|
250
|
+
|
|
251
|
+
const cached: CursorTokenResult | undefined = isUsable(keychain.accessToken)
|
|
252
|
+
? { accessToken: keychain.accessToken, source: CredentialSource.CliKeychain }
|
|
253
|
+
: isUsable(vscdb.accessToken)
|
|
254
|
+
? { accessToken: vscdb.accessToken, source: CredentialSource.IdeVscdb }
|
|
255
|
+
: undefined;
|
|
256
|
+
|
|
257
|
+
// Fast path: a locally stored token is still valid, so no network at all.
|
|
258
|
+
if (cached && !options?.forceRefresh) return cached;
|
|
259
|
+
|
|
260
|
+
const keychainRefreshed = await tryRefresh(keychain.refreshToken);
|
|
261
|
+
if (keychainRefreshed) {
|
|
262
|
+
return { accessToken: keychainRefreshed, source: CredentialSource.CliKeychainRefresh };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Skip a second identical exchange when the CLI mirrored its token into the IDE.
|
|
266
|
+
if (vscdb.refreshToken && vscdb.refreshToken !== keychain.refreshToken) {
|
|
267
|
+
const vscdbRefreshed = await tryRefresh(vscdb.refreshToken);
|
|
268
|
+
if (vscdbRefreshed)
|
|
269
|
+
return { accessToken: vscdbRefreshed, source: CredentialSource.IdeVscdbRefresh };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// forceRefresh could not improve on what we already had — keep it rather than
|
|
273
|
+
// reporting "not logged in" for a token that is still valid.
|
|
274
|
+
return cached;
|
|
275
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent / opt-out for reusing Cursor CLI / IDE system credentials.
|
|
3
|
+
*
|
|
4
|
+
* Default: allow system credential reuse (Keychain + state.vscdb + WSL host).
|
|
5
|
+
* Opt out with PI_CURSOR_SYSTEM_CREDENTIALS=0|false|off|deny.
|
|
6
|
+
* Force-enable with PI_CURSOR_SYSTEM_CREDENTIALS=1|true|on|allow.
|
|
7
|
+
*/
|
|
8
|
+
import { SystemCredentialPolicy } from "../types/enums.js";
|
|
9
|
+
import { isFalsyEnv, isTruthyEnv } from "../utils/util.js";
|
|
10
|
+
|
|
11
|
+
export { SystemCredentialPolicy, type SystemCredentialPolicyType } from "../types/enums.js";
|
|
12
|
+
|
|
13
|
+
export function resolveSystemCredentialPolicy(
|
|
14
|
+
envValue: string | undefined = process.env.PI_CURSOR_SYSTEM_CREDENTIALS,
|
|
15
|
+
): SystemCredentialPolicy {
|
|
16
|
+
if (!envValue?.trim()) return SystemCredentialPolicy.Allow;
|
|
17
|
+
if (isFalsyEnv(envValue)) return SystemCredentialPolicy.Deny;
|
|
18
|
+
if (isTruthyEnv(envValue)) return SystemCredentialPolicy.Allow;
|
|
19
|
+
// Unknown values fail closed so misconfiguration never silently scrapes credentials.
|
|
20
|
+
return SystemCredentialPolicy.Deny;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function systemCredentialsAllowed(envValue?: string): boolean {
|
|
24
|
+
return resolveSystemCredentialPolicy(envValue) === SystemCredentialPolicy.Allow;
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export {
|
|
2
|
+
generateCursorAuthParams,
|
|
3
|
+
pollCursorAuth,
|
|
4
|
+
refreshCursorToken,
|
|
5
|
+
getTokenExpiry,
|
|
6
|
+
getCursorAccessTokenFromEnv,
|
|
7
|
+
createCursorAuthClient,
|
|
8
|
+
type CursorAuthParams,
|
|
9
|
+
type CursorCredentials,
|
|
10
|
+
} from "./oauth.js";
|
|
11
|
+
export {
|
|
12
|
+
resolveSystemCursorAccessToken,
|
|
13
|
+
getCursorKeychainToken,
|
|
14
|
+
getCursorVscdbToken,
|
|
15
|
+
windowsUsernameFromEnv,
|
|
16
|
+
type CredentialSource,
|
|
17
|
+
type CursorTokenResult,
|
|
18
|
+
} from "./cli-credentials.js";
|
|
19
|
+
export {
|
|
20
|
+
systemCredentialsAllowed,
|
|
21
|
+
resolveSystemCredentialPolicy,
|
|
22
|
+
type SystemCredentialPolicy,
|
|
23
|
+
} from "./consent.js";
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor OAuth authentication via PKCE.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Generate PKCE verifier + challenge
|
|
6
|
+
* 2. Open browser to cursor.com/loginDeepControl
|
|
7
|
+
* 3. Poll api2.cursor.sh/auth/poll until tokens arrive
|
|
8
|
+
* 4. Refresh via api2.cursor.sh/auth/exchange_user_api_key
|
|
9
|
+
*
|
|
10
|
+
* Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { redactSecrets } from "../utils/security.js";
|
|
14
|
+
|
|
15
|
+
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
|
16
|
+
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
|
17
|
+
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
|
|
18
|
+
const POLL_MAX_ATTEMPTS = 150;
|
|
19
|
+
const POLL_BASE_DELAY = 1000;
|
|
20
|
+
const POLL_MAX_DELAY = 10_000;
|
|
21
|
+
const POLL_BACKOFF_MULTIPLIER = 1.2;
|
|
22
|
+
const AUTH_REQUEST_TIMEOUT_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
function authRequestSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
25
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
26
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseTokenResponse(
|
|
30
|
+
value: unknown,
|
|
31
|
+
endpoint: string,
|
|
32
|
+
): {
|
|
33
|
+
accessToken: string;
|
|
34
|
+
refreshToken?: string;
|
|
35
|
+
} {
|
|
36
|
+
if (!value || typeof value !== "object") {
|
|
37
|
+
throw new Error(`${endpoint} returned an invalid token response`);
|
|
38
|
+
}
|
|
39
|
+
const record = value as Record<string, unknown>;
|
|
40
|
+
if (typeof record.accessToken !== "string" || !record.accessToken.trim()) {
|
|
41
|
+
throw new Error(`${endpoint} returned no access token`);
|
|
42
|
+
}
|
|
43
|
+
if (record.refreshToken !== undefined && typeof record.refreshToken !== "string") {
|
|
44
|
+
throw new Error(`${endpoint} returned an invalid refresh token`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
accessToken: record.accessToken,
|
|
48
|
+
...(typeof record.refreshToken === "string" ? { refreshToken: record.refreshToken } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── PKCE ──
|
|
53
|
+
|
|
54
|
+
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
|
55
|
+
const verifierBytes = new Uint8Array(96);
|
|
56
|
+
crypto.getRandomValues(verifierBytes);
|
|
57
|
+
const verifier = Buffer.from(verifierBytes).toString("base64url");
|
|
58
|
+
|
|
59
|
+
const data = new TextEncoder().encode(verifier);
|
|
60
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
61
|
+
const challenge = Buffer.from(hashBuffer).toString("base64url");
|
|
62
|
+
|
|
63
|
+
return { verifier, challenge };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Login params ──
|
|
67
|
+
|
|
68
|
+
export interface CursorAuthParams {
|
|
69
|
+
verifier: string;
|
|
70
|
+
challenge: string;
|
|
71
|
+
uuid: string;
|
|
72
|
+
loginUrl: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CursorAuthClientDependencies {
|
|
76
|
+
fetch?: typeof fetch;
|
|
77
|
+
generatePkce?: () => Promise<{ verifier: string; challenge: string; uuid?: string }>;
|
|
78
|
+
randomUUID?: () => string;
|
|
79
|
+
sleep?: (ms: number) => Promise<void>;
|
|
80
|
+
requestTimeoutMs?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CursorAuthLoginCallbacks {
|
|
84
|
+
onAuth(payload: { url: string }): void | Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createCursorAuthClient(deps: CursorAuthClientDependencies = {}) {
|
|
88
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
89
|
+
const requestTimeoutMs = deps.requestTimeoutMs ?? AUTH_REQUEST_TIMEOUT_MS;
|
|
90
|
+
const sleepImpl =
|
|
91
|
+
deps.sleep ??
|
|
92
|
+
((ms: number) =>
|
|
93
|
+
new Promise<void>((resolve) => {
|
|
94
|
+
setTimeout(resolve, ms);
|
|
95
|
+
}));
|
|
96
|
+
const generatePkceImpl: () => Promise<{ verifier: string; challenge: string; uuid?: string }> =
|
|
97
|
+
deps.generatePkce ?? generatePKCE;
|
|
98
|
+
const randomUUIDImpl = deps.randomUUID ?? (() => crypto.randomUUID());
|
|
99
|
+
|
|
100
|
+
async function sleepForPoll(ms: number, signal?: AbortSignal): Promise<void> {
|
|
101
|
+
if (!signal) {
|
|
102
|
+
await sleepImpl(ms);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (signal.aborted) throw new Error("Cursor authentication polling aborted");
|
|
106
|
+
let rejectAbort: ((reason: Error) => void) | undefined;
|
|
107
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
108
|
+
rejectAbort = reject;
|
|
109
|
+
});
|
|
110
|
+
const onAbort = () => rejectAbort?.(new Error("Cursor authentication polling aborted"));
|
|
111
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
try {
|
|
113
|
+
await Promise.race([sleepImpl(ms), aborted]);
|
|
114
|
+
} finally {
|
|
115
|
+
signal.removeEventListener("abort", onAbort);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function generateParams(): Promise<CursorAuthParams> {
|
|
120
|
+
const { verifier, challenge, uuid: injectedUuid } = await generatePkceImpl();
|
|
121
|
+
const uuid = injectedUuid ?? randomUUIDImpl();
|
|
122
|
+
|
|
123
|
+
const params = new URLSearchParams({
|
|
124
|
+
challenge,
|
|
125
|
+
uuid,
|
|
126
|
+
mode: "login",
|
|
127
|
+
redirectTarget: "cli",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`;
|
|
131
|
+
return { verifier, challenge, uuid, loginUrl };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function poll(
|
|
135
|
+
uuid: string,
|
|
136
|
+
verifier: string,
|
|
137
|
+
options?: { signal?: AbortSignal },
|
|
138
|
+
): Promise<{ accessToken: string; refreshToken: string }> {
|
|
139
|
+
let delay = POLL_BASE_DELAY;
|
|
140
|
+
let consecutiveErrors = 0;
|
|
141
|
+
|
|
142
|
+
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
|
143
|
+
await sleepForPoll(delay, options?.signal);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetchImpl(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`, {
|
|
147
|
+
signal: authRequestSignal(options?.signal, requestTimeoutMs),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (response.status === 404) {
|
|
151
|
+
consecutiveErrors = 0;
|
|
152
|
+
delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (response.ok) {
|
|
157
|
+
const data = parseTokenResponse(await response.json(), "Cursor authentication polling");
|
|
158
|
+
if (!data.refreshToken) {
|
|
159
|
+
throw new Error("Cursor authentication polling returned no refresh token");
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
accessToken: data.accessToken,
|
|
163
|
+
refreshToken: data.refreshToken,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new Error(`Poll failed: ${response.status}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (options?.signal?.aborted) {
|
|
170
|
+
throw new Error("Cursor authentication polling aborted", { cause: error });
|
|
171
|
+
}
|
|
172
|
+
consecutiveErrors++;
|
|
173
|
+
if (consecutiveErrors >= 3) {
|
|
174
|
+
throw new Error("Too many consecutive errors during Cursor auth polling", {
|
|
175
|
+
cause: error,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw new Error("Cursor authentication polling timeout");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refreshToken(
|
|
185
|
+
refreshToken: string,
|
|
186
|
+
options?: { signal?: AbortSignal },
|
|
187
|
+
): Promise<CursorCredentials> {
|
|
188
|
+
const response = await fetchImpl(CURSOR_REFRESH_URL, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
Authorization: `Bearer ${refreshToken}`,
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
},
|
|
194
|
+
body: "{}",
|
|
195
|
+
signal: authRequestSignal(options?.signal, requestTimeoutMs),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const error = await response.text();
|
|
200
|
+
throw new Error(`Cursor token refresh failed: ${redactSecrets(error)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const data = parseTokenResponse(await response.json(), "Cursor token refresh");
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
access: data.accessToken,
|
|
207
|
+
refresh: data.refreshToken || refreshToken,
|
|
208
|
+
expires: getTokenExpiry(data.accessToken),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function login(
|
|
213
|
+
callbacks: CursorAuthLoginCallbacks,
|
|
214
|
+
options?: { signal?: AbortSignal },
|
|
215
|
+
): Promise<CursorCredentials> {
|
|
216
|
+
const { verifier, uuid, loginUrl } = await generateParams();
|
|
217
|
+
await callbacks.onAuth({ url: loginUrl });
|
|
218
|
+
const { accessToken, refreshToken } = await poll(uuid, verifier, options);
|
|
219
|
+
return {
|
|
220
|
+
access: accessToken,
|
|
221
|
+
refresh: refreshToken,
|
|
222
|
+
expires: getTokenExpiry(accessToken),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
generateParams,
|
|
228
|
+
login,
|
|
229
|
+
poll,
|
|
230
|
+
refreshToken,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
|
|
235
|
+
return createCursorAuthClient().generateParams();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Poll for auth completion ──
|
|
239
|
+
|
|
240
|
+
export async function pollCursorAuth(
|
|
241
|
+
uuid: string,
|
|
242
|
+
verifier: string,
|
|
243
|
+
options?: { signal?: AbortSignal },
|
|
244
|
+
): Promise<{ accessToken: string; refreshToken: string }> {
|
|
245
|
+
return createCursorAuthClient().poll(uuid, verifier, options);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Token refresh ──
|
|
249
|
+
|
|
250
|
+
export interface CursorCredentials {
|
|
251
|
+
access: string;
|
|
252
|
+
refresh: string;
|
|
253
|
+
expires: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function refreshCursorToken(
|
|
257
|
+
refreshToken: string,
|
|
258
|
+
options?: { signal?: AbortSignal },
|
|
259
|
+
): Promise<CursorCredentials> {
|
|
260
|
+
return createCursorAuthClient().refreshToken(refreshToken, options);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── JWT expiry extraction ──
|
|
264
|
+
|
|
265
|
+
export function getCursorAccessTokenFromEnv(): string | undefined {
|
|
266
|
+
const token = process.env.CURSOR_ACCESS_TOKEN?.trim();
|
|
267
|
+
return token || undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getTokenExpiry(token: string): number {
|
|
271
|
+
try {
|
|
272
|
+
const parts = token.split(".");
|
|
273
|
+
if (parts.length !== 3 || !parts[1]) {
|
|
274
|
+
return Date.now() + 3600 * 1000;
|
|
275
|
+
}
|
|
276
|
+
const decoded = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
277
|
+
if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
|
|
278
|
+
return decoded.exp * 1000 - 5 * 60 * 1000;
|
|
279
|
+
}
|
|
280
|
+
} catch {}
|
|
281
|
+
return Date.now() + 3600 * 1000;
|
|
282
|
+
}
|