diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index db425425..e5459583 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -17,6 +17,8 @@ const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." + const SAFE_STORAGE_PREFIX = "cursor-safe-storage:v1:" + const CACHED_ACCESS_TOKEN_FILE = "access-token.json" function readStateValue(ctx, key) { try { @@ -83,9 +85,121 @@ } } + function isSafeStorageValue(value) { + return typeof value === "string" && value.indexOf(SAFE_STORAGE_PREFIX) === 0 + } + + function decodeStoredToken(ctx, raw) { + if (typeof raw !== "string" || raw.length === 0) return null + if (!isSafeStorageValue(raw)) return raw + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.decrypt !== "function") { + throw "Cursor secure storage is unavailable." + } + try { + const plain = ctx.host.cursorAuth.decrypt(raw) + if (typeof plain !== "string" || plain.length === 0) { + throw "Cursor secure storage could not be read." + } + return plain + } catch (e) { + if (typeof e === "string") throw e + ctx.host.log.error("cursor secure storage decrypt failed") + throw "Cursor secure storage could not be read." + } + } + + function cachedAccessTokenPath(ctx) { + return ctx.app.pluginDataDir + "/" + CACHED_ACCESS_TOKEN_FILE + } + + function refreshFingerprint(ctx, refreshTokenValue) { + if (typeof refreshTokenValue !== "string" || refreshTokenValue.length === 0) return null + if (!ctx.host.crypto || typeof ctx.host.crypto.sha256Hex !== "function") return null + return ctx.host.crypto.sha256Hex(refreshTokenValue) + } + + function readCacheRecord(ctx) { + const path = cachedAccessTokenPath(ctx) + try { + if (!ctx.host.fs.exists(path)) return null + const data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) + if (!data || typeof data.protected !== "string" || data.protected.length === 0) { + if (data && typeof data.accessToken === "string" && data.accessToken.length > 0) { + discardCachedAccessToken(ctx) + } + return null + } + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.unprotect !== "function") return null + const token = ctx.host.cursorAuth.unprotect(data.protected) + if (typeof token !== "string" || token.length === 0) { + discardCachedAccessToken(ctx) + return null + } + return { + token: token, + refreshFingerprint: typeof data.refreshFingerprint === "string" ? data.refreshFingerprint : "", + } + } catch (e) { + ctx.host.log.warn("failed to read cached access token: " + String(e)) + discardCachedAccessToken(ctx) + return null + } + } + + function writeCacheRecord(ctx, accessToken, refreshTokenValue) { + const fingerprint = refreshFingerprint(ctx, refreshTokenValue) + if (!fingerprint) { + ctx.host.log.warn("cannot fingerprint the refresh token; not caching the access token") + return + } + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.protect !== "function") { + ctx.host.log.warn("cannot protect the access token; not caching it") + return + } + try { + const protectedToken = ctx.host.cursorAuth.protect(accessToken) + if (typeof protectedToken !== "string" || protectedToken.length === 0 || protectedToken === accessToken) { + ctx.host.log.warn("cannot protect the access token; not caching it") + return + } + ctx.host.fs.writeText(cachedAccessTokenPath(ctx), JSON.stringify({ + protected: protectedToken, + refreshFingerprint: fingerprint, + })) + } catch (e) { + ctx.host.log.warn("failed to cache access token: " + String(e)) + } + } + + function discardCachedAccessToken(ctx) { + const path = cachedAccessTokenPath(ctx) + try { + if (!ctx.host.fs.exists(path)) return + ctx.host.fs.writeText(path, "{}") + } catch (e) { + ctx.host.log.warn("failed to discard cached access token: " + String(e)) + } + } + + function takeCachedAccessToken(ctx, loadedAccessToken, refreshTokenValue) { + const record = readCacheRecord(ctx) + if (!record) return null + const expected = refreshFingerprint(ctx, refreshTokenValue) + const cachedSubject = getTokenSubject(ctx, record.token) + const loadedSubject = getTokenSubject(ctx, loadedAccessToken) + if (!expected || record.refreshFingerprint !== expected || !cachedSubject || !loadedSubject || cachedSubject !== loadedSubject) { + discardCachedAccessToken(ctx) + return null + } + return record.token + } + function loadAuthState(ctx) { - const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken") - const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteAccessRaw = readStateValue(ctx, "cursorAuth/accessToken") + const sqliteRefreshRaw = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteAccessToken = decodeStoredToken(ctx, sqliteAccessRaw) + const sqliteRefreshToken = decodeStoredToken(ctx, sqliteRefreshRaw) + const writeSqlite = !isSafeStorageValue(sqliteAccessRaw) && !isSafeStorageValue(sqliteRefreshRaw) const sqliteMembershipTypeRaw = readStateValue(ctx, "cursorAuth/stripeMembershipType") const sqliteMembershipType = typeof sqliteMembershipTypeRaw === "string" ? sqliteMembershipTypeRaw.trim().toLowerCase() @@ -107,6 +221,7 @@ refreshToken: keychainRefreshToken, membershipType: null, source: "keychain", + writeSqlite: false, } } @@ -115,6 +230,7 @@ refreshToken: sqliteRefreshToken, membershipType: sqliteMembershipType || null, source: "sqlite", + writeSqlite: writeSqlite, } } @@ -125,6 +241,7 @@ refreshToken: keychainRefreshToken, membershipType: null, source: "keychain", + writeSqlite: false, } } @@ -132,6 +249,7 @@ accessToken: null, refreshToken: null, source: null, + writeSqlite: false, } } @@ -143,11 +261,20 @@ return subject || null } - function persistAccessToken(ctx, source, accessToken) { + function persistAccessToken(ctx, source, accessToken, writeSqlite, refreshTokenValue) { if (source === "keychain") { + discardCachedAccessToken(ctx) return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) } - return writeStateValue(ctx, "cursorAuth/accessToken", accessToken) + if (!writeSqlite) { + writeCacheRecord(ctx, accessToken, refreshTokenValue) + ctx.host.log.info("refresh succeeded, token kept for the next probe") + return true + } + discardCachedAccessToken(ctx) + const wrote = writeStateValue(ctx, "cursorAuth/accessToken", accessToken) + if (wrote) ctx.host.log.info("refresh succeeded, token persisted") + return wrote } function getTokenExpiration(ctx, token) { @@ -166,7 +293,7 @@ }) } - function refreshToken(ctx, refreshTokenValue, source) { + function refreshToken(ctx, refreshTokenValue, source, writeSqlite) { if (!refreshTokenValue) { ctx.host.log.warn("refresh skipped: no refresh token") return null @@ -192,6 +319,7 @@ const shouldLogout = errorInfo && errorInfo.shouldLogout === true ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) if (shouldLogout) { + discardCachedAccessToken(ctx) throw "Session expired. " + LOGIN_HINT } throw "Token expired. " + LOGIN_HINT @@ -211,6 +339,7 @@ // Check if server wants us to logout if (body.shouldLogout === true) { ctx.host.log.error("refresh response indicates shouldLogout=true") + discardCachedAccessToken(ctx) throw "Session expired. " + LOGIN_HINT } @@ -221,8 +350,7 @@ } // Persist updated access token to source where auth was loaded from. - persistAccessToken(ctx, source, newAccessToken) - ctx.host.log.info("refresh succeeded, token persisted") + persistAccessToken(ctx, source, newAccessToken, writeSqlite, refreshTokenValue) // Note: Cursor refresh returns access_token which is used as both // access and refresh token in some flows @@ -556,6 +684,7 @@ const authSource = authState.source if (!accessToken && !refreshTokenValue) { + discardCachedAccessToken(ctx) ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain") throw "Not logged in. " + LOGIN_HINT } @@ -563,13 +692,19 @@ ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) const nowMs = Date.now() + const cached = authSource === "sqlite" && !authState.writeSqlite && needsRefresh(ctx, accessToken, nowMs) + ? takeCachedAccessToken(ctx, accessToken, refreshTokenValue) + : null + if (cached && !needsRefresh(ctx, cached, nowMs)) { + accessToken = cached + } // Proactively refresh if token is expired or about to expire if (needsRefresh(ctx, accessToken, nowMs)) { ctx.host.log.info("token needs refresh (expired or expiring soon)") let refreshed = null try { - refreshed = refreshToken(ctx, refreshTokenValue, authSource) + refreshed = refreshToken(ctx, refreshTokenValue, authSource, authState.writeSqlite) } catch (e) { // If refresh fails but we have an access token, try it anyway ctx.host.log.warn("refresh failed but have access token, will try: " + String(e)) @@ -601,7 +736,13 @@ refresh: () => { ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true - const refreshed = refreshToken(ctx, refreshTokenValue, authSource) + const failed = accessToken + discardCachedAccessToken(ctx) + if (authState.accessToken && authState.accessToken !== failed && !needsRefresh(ctx, authState.accessToken, Date.now())) { + accessToken = authState.accessToken + return authState.accessToken + } + const refreshed = refreshToken(ctx, refreshTokenValue, authSource, authState.writeSqlite) if (refreshed) accessToken = refreshed return refreshed }, diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 15952b7e..c5a08f53 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -13,6 +13,18 @@ function makeJwt(payload) { return `a.${jwtPayload}.c` } +function cursorAuthMock(decrypt) { + return { + decrypt: vi.fn(decrypt), + protect: (plain) => "protected:" + plain, + unprotect: (stored) => { + const text = String(stored) + if (text.indexOf("protected:") !== 0) throw new Error("bad cache") + return text.slice("protected:".length) + }, + } +} + describe("cursor plugin", () => { beforeEach(() => { delete globalThis.__openusage_plugin @@ -2090,4 +2102,366 @@ describe("cursor plugin", () => { expect(result.lines.find((line) => line.label === "Grok Bot usage")).toBeUndefined() expect(failing.host.log.warn).toHaveBeenCalledWith("Grok Bot usage returned status=503") }) + + it("decrypts safe-storage tokens and does not write them back to sqlite", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const storedRefresh = "cursor-safe-storage:v1:refresh" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: storedRefresh }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => { + if (raw === storedAccess) return expired + if (raw === storedRefresh) return "refresh-plain" + throw new Error("unexpected decrypt") + }) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + const body = JSON.parse(opts.bodyText) + expect(body.refresh_token).toBe("refresh-plain") + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy() + expect(ctx.host.cursorAuth.decrypt).toHaveBeenCalledWith(storedAccess) + expect(ctx.host.cursorAuth.decrypt).toHaveBeenCalledWith(storedRefresh) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("reuses the safe-storage access token from pluginDataDir on a fresh probe", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(1) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) + expect(saved.accessToken).toBeUndefined() + expect(ctx.host.cursorAuth.unprotect(saved.protected)).toBe(fresh) + expect(saved.refreshFingerprint).toBe(ctx.host.crypto.sha256Hex("refresh-plain")) + }) + + it("drops the cached Cursor token when the loaded account changes", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expiredOne = makeJwt({ exp: 1, sub: "user-1" }) + const freshOne = makeJwt({ exp: 9999999999, sub: "user-1" }) + const expiredTwo = makeJwt({ exp: 1, sub: "user-2" }) + const freshTwo = makeJwt({ exp: 9999999999, sub: "user-2" }) + let account = "user-1" + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => { + if (String(raw).endsWith("access")) return account === "user-1" ? expiredOne : expiredTwo + return "refresh-plain" + }) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + const access_token = account === "user-1" ? freshOne : freshTwo + return { status: 200, bodyText: JSON.stringify({ access_token }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + account = "user-2" + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) + expect(ctx.host.cursorAuth.unprotect(saved.protected)).toBe(freshTwo) + }) + + it("forgets the cached Cursor token when refresh demands logout", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let usageCalls = 0 + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("/oauth/token")) { + refreshCalls += 1 + if (refreshCalls > 1) return { status: 200, bodyText: JSON.stringify({ shouldLogout: true }) } + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + if (url.includes("GetCurrentPeriodUsage")) { + usageCalls += 1 + if (usageCalls === 1) { + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + } + return { status: 401, bodyText: "" } + } + return { status: 200, bodyText: "{}" } + }) + + ;(await loadPlugin()).probe(ctx) + vi.resetModules() + delete globalThis.__openusage_plugin + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") + }) + + it("does not write sqlite when only the refresh token is safe-storage", async () => { + const ctx = makeCtx() + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("refresh") ? "refresh-plain" : raw)) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("drops the cached token when no login is found", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(ctx.app.pluginDataDir + "/access-token.json", JSON.stringify({ + protected: "protected:old", + refreshFingerprint: "abc", + })) + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([])) + ctx.host.cursorAuth = cursorAuthMock(() => null) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Not logged in") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") + }) + + it("refreshes again when the loaded token has no subject", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const noSubject = makeJwt({ exp: 1 }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let loaded = expired + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? loaded : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + loaded = noSubject + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("refreshes again when the refresh token changes", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let refreshPlain = "refresh-plain" + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : refreshPlain)) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + refreshPlain = "refresh-other" + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("keeps a fresh database token ahead of the cache", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const cachedFresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + const databaseFresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let loaded = expired + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? loaded : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: cachedFresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + loaded = databaseFresh + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(1) + const usageAuth = ctx.host.http.request.mock.calls + .filter((call) => String(call[0].url).includes("GetCurrentPeriodUsage")) + .map((call) => call[0].headers.Authorization) + expect(usageAuth[usageAuth.length - 1]).toBe("Bearer " + databaseFresh) + }) + + it("drops the cached token when usage returns 401 and refresh fails", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let phase = "first" + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("/oauth/token")) { + if (phase === "second") return { status: 500, bodyText: "" } + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + if (url.includes("GetCurrentPeriodUsage") && phase === "second") return { status: 401, bodyText: "" } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + phase = "second" + vi.resetModules() + delete globalThis.__openusage_plugin + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Token expired") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") + }) }) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a86439e9..a5dd156a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ winreg = "0.55" # Already pulled in by tauri; listed so we can read the applied backdrop result. window-vibrancy = "0.6" windows-version = "0.1" -windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } # Already pulled in by the notification plugin; used to read Do Not Disturb. windows = { version = "0.62", features = ["UI_Notifications", "Win32_System_Com", "Win32_UI_Accessibility"] } diff --git a/src-tauri/src/plugin_engine/cursor_safe_storage.rs b/src-tauri/src/plugin_engine/cursor_safe_storage.rs new file mode 100644 index 00000000..8844fe43 --- /dev/null +++ b/src-tauri/src/plugin_engine/cursor_safe_storage.rs @@ -0,0 +1,300 @@ +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +pub const SAFE_STORAGE_PREFIX: &str = "cursor-safe-storage:v1:"; + +pub fn decrypt_stored_value(raw: &str, key: &[u8]) -> Result { + let Some(encoded) = raw.strip_prefix(SAFE_STORAGE_PREFIX) else { + return Ok(raw.to_string()); + }; + let blob = BASE64_STANDARD + .decode(encoded.trim()) + .map_err(|_| "decryption failed".to_string())?; + decrypt_v10_blob(key, &blob) +} + +pub fn decrypt_v10_blob(key: &[u8], blob: &[u8]) -> Result { + if key.len() != 32 { + return Err("Windows DPAPI returned an unexpected key".to_string()); + } + if blob.len() < 3 || &blob[..3] != b"v10" { + return Err("unrecognized encryption version".to_string()); + } + if blob.len() < 3 + 12 + 16 { + return Err("encrypted value is truncated".to_string()); + } + let nonce = Nonce::from_slice(&blob[3..15]); + let tag_at = blob.len() - 16; + let mut sealed = Vec::with_capacity(tag_at - 15 + 16); + sealed.extend_from_slice(&blob[15..tag_at]); + sealed.extend_from_slice(&blob[tag_at..]); + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| "decryption failed".to_string())?; + let plain = cipher + .decrypt(nonce, sealed.as_ref()) + .map_err(|_| "decryption failed".to_string())?; + let text = String::from_utf8(plain).map_err(|_| "decrypted value is not a token".to_string())?; + if text.is_empty() || !text.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err("decrypted value is not a token".to_string()); + } + Ok(text) +} + +pub fn read_wrapped_key(local_state_json: &str) -> Result, String> { + let json: serde_json::Value = serde_json::from_str(local_state_json) + .map_err(|_| "could not read Cursor's Local State".to_string())?; + let encoded = json + .pointer("/os_crypt/encrypted_key") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let wrapped = BASE64_STANDARD + .decode(encoded) + .map_err(|_| "Cursor's Local State has no DPAPI key".to_string())?; + if wrapped.len() < 5 || &wrapped[..5] != b"DPAPI" { + return Err("Cursor's Local State has no DPAPI key".to_string()); + } + Ok(wrapped[5..].to_vec()) +} + +#[cfg(windows)] +fn dpapi_call(protect: bool, payload: &[u8], failure: &str) -> Result, String> { + use std::ptr; + use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; + use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB}; + + const UI_FORBIDDEN: u32 = 0x1; + let input = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(payload.len()).map_err(|_| failure.to_string())?, + pbData: payload.as_ptr() as *mut u8, + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: ptr::null_mut(), + }; + let ok = unsafe { + if protect { + CryptProtectData( + &input, + ptr::null(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + UI_FORBIDDEN, + &mut output, + ) + } else { + CryptUnprotectData( + &input, + ptr::null_mut(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + UI_FORBIDDEN, + &mut output, + ) + } + }; + if ok == 0 || output.pbData.is_null() { + return Err(failure.to_string()); + } + let bytes = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + unsafe { + LocalFree(output.pbData as HLOCAL); + } + Ok(bytes) +} + +#[cfg(windows)] +fn dpapi_protect(plain: &[u8]) -> Result, String> { + dpapi_call(true, plain, "Windows DPAPI could not protect the token") +} + +#[cfg(windows)] +fn dpapi_unprotect(payload: &[u8]) -> Result, String> { + dpapi_call(false, payload, "Windows DPAPI could not unwrap the key") +} + +#[cfg(windows)] +pub fn unwrap_dpapi(payload: &[u8]) -> Result, String> { + let key = dpapi_unprotect(payload)?; + if key.len() != 32 { + return Err("Windows DPAPI returned an unexpected key".to_string()); + } + Ok(key) +} + +pub fn protect_for_cache(plain: &str) -> Result { + if plain.is_empty() { + return Err("nothing to protect".to_string()); + } + #[cfg(windows)] + { + let protected = dpapi_protect(plain.as_bytes())?; + return Ok(BASE64_STANDARD.encode(protected)); + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + +pub fn unprotect_for_cache(stored: &str) -> Result { + if stored.is_empty() { + return Err("cached token could not be read".to_string()); + } + #[cfg(windows)] + { + let blob = BASE64_STANDARD + .decode(stored.trim()) + .map_err(|_| "cached token could not be read".to_string())?; + let plain = dpapi_unprotect(&blob).map_err(|_| "cached token could not be read".to_string())?; + let text = String::from_utf8(plain).map_err(|_| "cached token could not be read".to_string())?; + if text.is_empty() { + return Err("cached token could not be read".to_string()); + } + return Ok(text); + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + +pub fn decrypt_cursor_value(raw: &str) -> Result { + if !raw.starts_with(SAFE_STORAGE_PREFIX) { + return Ok(raw.to_string()); + } + #[cfg(windows)] + { + let path = dirs::data_dir() + .ok_or_else(|| "could not read Cursor's Local State".to_string())? + .join("Cursor") + .join("Local State"); + let json = std::fs::read_to_string(&path) + .map_err(|_| "could not read Cursor's Local State".to_string())?; + let wrapped = read_wrapped_key(&json)?; + let key = unwrap_dpapi(&wrapped)?; + decrypt_stored_value(raw, &key) + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, + }; + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + + fn seal(key: &[u8; 32], nonce: &[u8; 12], plain: &str) -> Vec { + let cipher = Aes256Gcm::new_from_slice(key).expect("cipher"); + let sealed = cipher + .encrypt(nonce.into(), Payload { msg: plain.as_bytes(), aad: b"" }) + .expect("seal"); + let mut blob = b"v10".to_vec(); + blob.extend_from_slice(nonce); + blob.extend_from_slice(&sealed); + blob + } + + #[test] + fn decrypts_v10_and_passes_plaintext_through() { + let key = [7_u8; 32]; + let nonce = [9_u8; 12]; + let token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OX0.sig"; + let blob = seal(&key, &nonce, token); + let stored = format!( + "{}{}", + SAFE_STORAGE_PREFIX, + BASE64_STANDARD.encode(&blob) + ); + + assert_eq!(decrypt_stored_value(&stored, &key).expect("decrypt"), token); + assert_eq!( + decrypt_stored_value(token, &key).expect("plaintext"), + token + ); + } + + #[test] + fn rejects_bad_tag_truncated_blob_and_wrong_version() { + let key = [7_u8; 32]; + let mut blob = seal(&key, &[9_u8; 12], "token.ok"); + let last = blob.len() - 1; + blob[last] ^= 0xff; + let flipped = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(&blob)); + assert_eq!( + decrypt_stored_value(&flipped, &key).expect_err("tag"), + "decryption failed" + ); + + let short = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(b"v10short")); + assert_eq!( + decrypt_stored_value(&short, &key).expect_err("truncated"), + "encrypted value is truncated" + ); + + let other = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(b"v11abcdefghijklmnop")); + assert_eq!( + decrypt_stored_value(&other, &key).expect_err("version"), + "unrecognized encryption version" + ); + } + + #[test] + fn rejects_non_token_plaintext() { + let key = [7_u8; 32]; + let blob = seal(&key, &[9_u8; 12], "tem espaço"); + let stored = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(&blob)); + assert_eq!( + decrypt_stored_value(&stored, &key).expect_err("charset"), + "decrypted value is not a token" + ); + } + + #[test] + fn read_wrapped_key_strips_dpapi_prefix() { + let body = b"DPAPI0123456789abcdef0123456789ab"; + let json = format!( + r#"{{"os_crypt":{{"encrypted_key":"{}"}}}}"#, + BASE64_STANDARD.encode(body) + ); + let wrapped = read_wrapped_key(&json).expect("wrapped"); + assert_eq!(wrapped, b"0123456789abcdef0123456789ab"); + } + + #[test] + fn read_wrapped_key_rejects_other_prefixes() { + let json = format!( + r#"{{"os_crypt":{{"encrypted_key":"{}"}}}}"#, + BASE64_STANDARD.encode(b"APPBnot-dpapi") + ); + assert_eq!( + read_wrapped_key(&json).expect_err("appb"), + "Cursor's Local State has no DPAPI key" + ); + assert_eq!( + read_wrapped_key("{}").expect_err("missing"), + "Cursor's Local State has no DPAPI key" + ); + } + + #[cfg(windows)] + #[test] + fn dpapi_round_trip_returns_32_bytes() { + let key = [3_u8; 32]; + let protected = dpapi_protect(&key).expect("protect"); + let opened = unwrap_dpapi(&protected).expect("unwrap"); + assert_eq!(opened, key); + let cache = protect_for_cache("plain-token").expect("cache protect"); + assert_ne!(cache, "plain-token"); + assert_eq!(unprotect_for_cache(&cache).expect("cache unprotect"), "plain-token"); + } +} diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 502c2c5b..c983a20f 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -699,6 +699,12 @@ pub(crate) fn inject_host_api_with_deadline<'js>( inject_http(ctx, &host, plugin_id, deadline)?; inject_keychain(ctx, &host, plugin_id)?; inject_sqlite(ctx, &host, home.clone())?; + let cursor_id = account + .map(|account| account.base_id.as_str()) + .unwrap_or(plugin_id); + if cursor_id == "cursor" { + inject_cursor_auth(ctx, &host)?; + } inject_ls(ctx, &host, plugin_id)?; // An account copy reads the same kind of local logs as its base plugin (from its own home). A // WSL copy reads none: its logs live in WSL, far too slow to scan over \\wsl.localhost. @@ -719,6 +725,33 @@ pub(crate) fn inject_host_api_with_deadline<'js>( Ok(()) } +fn inject_cursor_auth<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + let cursor_auth = Object::new(ctx.clone())?; + cursor_auth.set( + "decrypt", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, raw: String| -> rquickjs::Result { + super::cursor_safe_storage::decrypt_cursor_value(&raw) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; + cursor_auth.set( + "protect", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, plain: String| -> rquickjs::Result { + super::cursor_safe_storage::protect_for_cache(&plain) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; + cursor_auth.set( + "unprotect", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, stored: String| -> rquickjs::Result { + super::cursor_safe_storage::unprotect_for_cache(&stored) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; + host.set("cursorAuth", cursor_auth)?; + Ok(()) +} + fn inject_log<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let log_obj = Object::new(ctx.clone())?; @@ -5703,4 +5736,51 @@ wait assert_eq!(status, "ok"); }); } + + #[test] + fn cursor_plugin_can_decrypt_plaintext_and_other_plugins_cannot() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "cursor", &app_data, "0.0.0").expect("inject"); + let plain: String = ctx + .eval(r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#) + .expect("plaintext decrypt"); + assert_eq!(plain, "plain-token"); + #[cfg(windows)] + { + let protected: String = ctx + .eval(r#"__openusage_ctx.host.cursorAuth.protect("plain-token")"#) + .expect("protect"); + assert_ne!(protected, "plain-token"); + let opened: String = ctx + .eval(format!( + r#"__openusage_ctx.host.cursorAuth.unprotect("{protected}")"# + )) + .expect("unprotect"); + assert_eq!(opened, "plain-token"); + } + }); + + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "claude", &app_data, "0.0.0").expect("inject"); + let missing: rquickjs::Result = ctx.eval( + r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#, + ); + assert!(missing.is_err(), "claude must not receive cursorAuth"); + }); + + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "cursor-x", &app_data, "0.0.0").expect("inject"); + let missing: rquickjs::Result = ctx.eval( + r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#, + ); + assert!(missing.is_err(), "cursor-x must not receive cursorAuth"); + }); + } } diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs index 95073bde..97c6025a 100644 --- a/src-tauri/src/plugin_engine/mod.rs +++ b/src-tauri/src/plugin_engine/mod.rs @@ -1,5 +1,6 @@ pub mod host_api; mod ccusage_cache; +mod cursor_safe_storage; pub(crate) mod http_gate; pub mod manifest; pub mod runtime; diff --git a/src/lib/plugin-errors.test.ts b/src/lib/plugin-errors.test.ts index 5b528099..784943c9 100644 --- a/src/lib/plugin-errors.test.ts +++ b/src/lib/plugin-errors.test.ts @@ -54,6 +54,8 @@ const FIXTURES: [string, PluginErrorKey][] = [ ["Team request-based usage data unavailable. Try again later.", "teamRequestUsageUnavailable"], ["Cursor request-based usage data unavailable. Try again later.", "requestUsageUnavailable"], ["No active Cursor subscription.", "noActiveSubscription"], + ["Cursor secure storage is unavailable.", "cursorSecureStorageUnavailable"], + ["Cursor secure storage could not be read.", "cursorSecureStorageUnreadable"], ["Total usage limit missing from API response.", "totalLimitMissing"], // devin ["Run devin auth login or sign in to Devin and try again.", "runOrSignIn"], diff --git a/src/lib/plugin-errors.ts b/src/lib/plugin-errors.ts index 611b23d7..80245a9f 100644 --- a/src/lib/plugin-errors.ts +++ b/src/lib/plugin-errors.ts @@ -36,6 +36,8 @@ export const PLUGIN_ERROR_PATTERNS: readonly { pattern: RegExp; key: PluginError { pattern: /^Request failed \(HTTP (?\d+)\)$/, key: "requestFailedHttpShort" }, { pattern: /^Could not parse usage data\.$/, key: "parseFailed" }, { pattern: /^WSL isn't running\. Start it to update this card\.$/, key: "wslNotRunning" }, + { pattern: /^Cursor secure storage is unavailable\.$/, key: "cursorSecureStorageUnavailable" }, + { pattern: /^Cursor secure storage could not be read\.$/, key: "cursorSecureStorageUnreadable" }, { pattern: /^Usage request failed\. Check your connection\.$/, key: "usageRequestFailed" }, { pattern: /^Usage request failed \(HTTP (?\d+)\)\. Try again later\.$/, key: "usageRequestFailedHttp" }, diff --git a/src/locales/en.lock.json b/src/locales/en.lock.json index 651ee002..d9d3fabe 100644 --- a/src/locales/en.lock.json +++ b/src/locales/en.lock.json @@ -424,5 +424,7 @@ "plugin-errors.pluginFailed": "6252c16f47", "plugin-errors.probeStartFailed": "0af5da2403", "plugin-errors.updateFailed": "c10c914d19", - "plugin-errors.wslNotRunning": "26e5198224" + "plugin-errors.wslNotRunning": "26e5198224", + "plugin-errors.cursorSecureStorageUnavailable": "0b699ea09f", + "plugin-errors.cursorSecureStorageUnreadable": "bb3834cfa0" } diff --git a/src/locales/plugin-errors/de-DE.ts b/src/locales/plugin-errors/de-DE.ts index e9bba07d..bda23b19 100644 --- a/src/locales/plugin-errors/de-DE.ts +++ b/src/locales/plugin-errors/de-DE.ts @@ -72,6 +72,8 @@ const deDE: PluginErrorMessages = { probeStartFailed: "Abfrage konnte nicht gestartet werden", updateFailed: "Daten konnten nicht aktualisiert werden. Erneut versuchen?", wslNotRunning: "WSL läuft nicht. Starten Sie WSL, um diese Karte zu aktualisieren.", + cursorSecureStorageUnavailable: "Der sichere Speicher von Cursor ist nicht verfügbar.", + cursorSecureStorageUnreadable: "Der sichere Speicher von Cursor konnte nicht gelesen werden.", } export default deDE diff --git a/src/locales/plugin-errors/en-US.ts b/src/locales/plugin-errors/en-US.ts index db81f8dc..f609aa39 100644 --- a/src/locales/plugin-errors/en-US.ts +++ b/src/locales/plugin-errors/en-US.ts @@ -72,6 +72,8 @@ const enUS = { probeStartFailed: "Failed to start probe", updateFailed: "Couldn't update data. Try again?", wslNotRunning: "WSL isn't running. Start it to update this card.", + cursorSecureStorageUnavailable: "Cursor secure storage is unavailable.", + cursorSecureStorageUnreadable: "Cursor secure storage could not be read.", } export default enUS diff --git a/src/locales/plugin-errors/es-419.ts b/src/locales/plugin-errors/es-419.ts index 682f18ce..adcadaad 100644 --- a/src/locales/plugin-errors/es-419.ts +++ b/src/locales/plugin-errors/es-419.ts @@ -72,6 +72,8 @@ const es419: PluginErrorMessages = { probeStartFailed: "No se pudo iniciar la consulta de datos", updateFailed: "No se pudieron actualizar los datos. ¿Quieres volver a intentarlo?", wslNotRunning: "WSL no se está ejecutando. Inícialo para actualizar esta tarjeta.", + cursorSecureStorageUnavailable: "El almacenamiento seguro de Cursor no está disponible.", + cursorSecureStorageUnreadable: "No se pudo leer el almacenamiento seguro de Cursor.", } export default es419 diff --git a/src/locales/plugin-errors/es-ES.ts b/src/locales/plugin-errors/es-ES.ts index 76512c11..7e4b5a7f 100644 --- a/src/locales/plugin-errors/es-ES.ts +++ b/src/locales/plugin-errors/es-ES.ts @@ -72,6 +72,8 @@ const esES: PluginErrorMessages = { probeStartFailed: "No se pudo iniciar la consulta de datos", updateFailed: "No se pudieron actualizar los datos. ¿Quieres volver a intentarlo?", wslNotRunning: "WSL no se está ejecutando. Inícialo para actualizar esta tarjeta.", + cursorSecureStorageUnavailable: "El almacenamiento seguro de Cursor no está disponible.", + cursorSecureStorageUnreadable: "No se ha podido leer el almacenamiento seguro de Cursor.", } export default esES diff --git a/src/locales/plugin-errors/fr-FR.ts b/src/locales/plugin-errors/fr-FR.ts index 9dbda80f..0c0168f7 100644 --- a/src/locales/plugin-errors/fr-FR.ts +++ b/src/locales/plugin-errors/fr-FR.ts @@ -72,6 +72,8 @@ const frFR: PluginErrorMessages = { probeStartFailed: "Impossible de démarrer la collecte des données", updateFailed: "Impossible de mettre à jour les données. Réessayer ?", wslNotRunning: "WSL n'est pas lancé. Démarrez-le pour mettre à jour cette carte.", + cursorSecureStorageUnavailable: "Le stockage sécurisé de Cursor n'est pas disponible.", + cursorSecureStorageUnreadable: "Impossible de lire le stockage sécurisé de Cursor.", } export default frFR diff --git a/src/locales/plugin-errors/it-IT.ts b/src/locales/plugin-errors/it-IT.ts index 14f1af5d..a7946111 100644 --- a/src/locales/plugin-errors/it-IT.ts +++ b/src/locales/plugin-errors/it-IT.ts @@ -72,6 +72,8 @@ const itIT: PluginErrorMessages = { probeStartFailed: "Impossibile avviare la raccolta dati", updateFailed: "Impossibile aggiornare i dati. Riprovare?", wslNotRunning: "WSL non è in esecuzione. Avvialo per aggiornare questa scheda.", + cursorSecureStorageUnavailable: "L'archivio protetto di Cursor non è disponibile.", + cursorSecureStorageUnreadable: "Impossibile leggere l'archivio protetto di Cursor.", } export default itIT diff --git a/src/locales/plugin-errors/ja-JP.ts b/src/locales/plugin-errors/ja-JP.ts index b24096f2..ac2aea7e 100644 --- a/src/locales/plugin-errors/ja-JP.ts +++ b/src/locales/plugin-errors/ja-JP.ts @@ -72,6 +72,8 @@ const jaJP: PluginErrorMessages = { probeStartFailed: "データの取得を開始できませんでした", updateFailed: "データを更新できませんでした。もう一度やり直しますか?", wslNotRunning: "WSL が実行されていません。このカードを更新するには WSL を起動してください。", + cursorSecureStorageUnavailable: "Cursor の安全なストレージは利用できません。", + cursorSecureStorageUnreadable: "Cursor の安全なストレージを読み取れませんでした。", } export default jaJP diff --git a/src/locales/plugin-errors/ko-KR.ts b/src/locales/plugin-errors/ko-KR.ts index 555c0531..da261b9b 100644 --- a/src/locales/plugin-errors/ko-KR.ts +++ b/src/locales/plugin-errors/ko-KR.ts @@ -72,6 +72,8 @@ const koKR: PluginErrorMessages = { probeStartFailed: "데이터 조회를 시작하지 못했습니다", updateFailed: "데이터를 업데이트하지 못했습니다. 다시 시도하시겠습니까?", wslNotRunning: "WSL이 실행 중이 아닙니다. 이 카드를 업데이트하려면 WSL을 시작하세요.", + cursorSecureStorageUnavailable: "Cursor 보안 저장소를 사용할 수 없습니다.", + cursorSecureStorageUnreadable: "Cursor 보안 저장소를 읽을 수 없습니다.", } export default koKR diff --git a/src/locales/plugin-errors/pt-BR.ts b/src/locales/plugin-errors/pt-BR.ts index fed5de2c..cc274494 100644 --- a/src/locales/plugin-errors/pt-BR.ts +++ b/src/locales/plugin-errors/pt-BR.ts @@ -72,6 +72,8 @@ const ptBR: PluginErrorMessages = { probeStartFailed: "Falha ao iniciar a coleta de dados", updateFailed: "Não foi possível atualizar os dados. Tentar novamente?", wslNotRunning: "O WSL não está em execução. Inicie-o para atualizar este cartão.", + cursorSecureStorageUnavailable: "O armazenamento seguro do Cursor não está disponível.", + cursorSecureStorageUnreadable: "Não foi possível ler o armazenamento seguro do Cursor.", } export default ptBR diff --git a/src/locales/plugin-errors/tr-TR.ts b/src/locales/plugin-errors/tr-TR.ts index a14d9d0f..3bce41e0 100644 --- a/src/locales/plugin-errors/tr-TR.ts +++ b/src/locales/plugin-errors/tr-TR.ts @@ -72,6 +72,8 @@ const trTR: PluginErrorMessages = { probeStartFailed: "Veri sorgusu başlatılamadı", updateFailed: "Veriler güncelleştirilemedi. Yeniden denensin mi?", wslNotRunning: "WSL çalışmıyor. Bu kartı güncellemek için WSL'yi başlatın.", + cursorSecureStorageUnavailable: "Cursor güvenli depolaması kullanılamıyor.", + cursorSecureStorageUnreadable: "Cursor güvenli depolaması okunamadı.", } export default trTR diff --git a/src/locales/plugin-errors/zh-CN.ts b/src/locales/plugin-errors/zh-CN.ts index d94c352c..8e82319c 100644 --- a/src/locales/plugin-errors/zh-CN.ts +++ b/src/locales/plugin-errors/zh-CN.ts @@ -72,6 +72,8 @@ const zhCN: PluginErrorMessages = { probeStartFailed: "无法启动数据查询", updateFailed: "无法更新数据。要重试吗?", wslNotRunning: "WSL 未运行。启动 WSL 以更新此卡片。", + cursorSecureStorageUnavailable: "Cursor 安全存储不可用。", + cursorSecureStorageUnreadable: "无法读取 Cursor 安全存储。", } export default zhCN