diff --git a/plugins/devin/plugin.js b/plugins/devin/plugin.js index 9cbadeb2..4f245d5f 100644 --- a/plugins/devin/plugin.js +++ b/plugins/devin/plugin.js @@ -3,7 +3,16 @@ var DEFAULT_API_SERVER_URL = "https://server.codeium.com" var CLOUD_COMPAT_VERSION = "1.108.2" var CREDENTIALS_PATH = "~/.local/share/devin/credentials.toml" - var STATE_DB = "~/Library/Application Support/Devin/User/globalStorage/state.vscdb" + var APP_AUTH_SOURCES = [ + { + source: "Devin app", + stateDb: "~/Library/Application Support/Devin/User/globalStorage/state.vscdb", + }, + { + source: "Devin - Next app", + stateDb: "~/Library/Application Support/Devin - Next/User/globalStorage/state.vscdb", + }, + ] var LOGIN_HINT = "Run devin auth login or sign in to Devin and try again." var QUOTA_HINT = "Devin quota data unavailable. Try again later." var DAY_MS = 24 * 60 * 60 * 1000 @@ -128,10 +137,10 @@ } } - function loadAppAuth(ctx) { + function readAppAuth(ctx, variant) { try { var rows = ctx.host.sqlite.query( - STATE_DB, + variant.stateDb, "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) @@ -141,10 +150,10 @@ return { apiKey: auth.apiKey, apiServerUrl: null, - source: "Devin app", + source: variant.source, } } catch (e) { - ctx.host.log.warn("failed to read Devin app auth: " + String(e)) + ctx.host.log.warn("failed to read " + variant.source + " auth: " + String(e)) return null } } @@ -278,23 +287,27 @@ function probe(ctx) { var sawApiKey = false var sawAuthFailure = false - var credentials = loadCredentialsFile(ctx) + var attempts = [] + var credentials = loadCredentialsFile(ctx) if (credentials) { sawApiKey = true + attempts.push(authFingerprint(credentials)) var credentialsAttempt = tryAuth(ctx, credentials) if (credentialsAttempt.output) return credentialsAttempt.output if (credentialsAttempt.authFailure) sawAuthFailure = true } - var appAuth = loadAppAuth(ctx) - if ( - appAuth && - (!credentials || - appAuth.apiKey !== credentials.apiKey || - effectiveApiServerUrl(appAuth) !== effectiveApiServerUrl(credentials)) - ) { + // Walk every app install (stable, then "- Next") and try each token the cloud + // hasn't already rejected, so a stale token in one install doesn't mask a + // valid one in another. Read each state DB only when we reach it, so a working + // earlier source short-circuits before we touch a later install's DB. + for (var i = 0; i < APP_AUTH_SOURCES.length; i++) { + var appAuth = readAppAuth(ctx, APP_AUTH_SOURCES[i]) + if (!appAuth) continue + if (alreadyAttempted(attempts, appAuth)) continue sawApiKey = true + attempts.push(authFingerprint(appAuth)) var appAttempt = tryAuth(ctx, appAuth) if (appAttempt.output) return appAttempt.output if (appAttempt.authFailure) sawAuthFailure = true @@ -305,5 +318,17 @@ throw LOGIN_HINT } + function authFingerprint(auth) { + return auth.apiKey + "\n" + effectiveApiServerUrl(auth) + } + + function alreadyAttempted(attempts, auth) { + var fingerprint = authFingerprint(auth) + for (var i = 0; i < attempts.length; i++) { + if (attempts[i] === fingerprint) return true + } + return false + } + globalThis.__openusage_plugin = { id: "devin", probe: probe } })() diff --git a/plugins/devin/plugin.test.js b/plugins/devin/plugin.test.js index b3058a2b..26be7a7f 100644 --- a/plugins/devin/plugin.test.js +++ b/plugins/devin/plugin.test.js @@ -3,6 +3,7 @@ import { makeCtx } from "../test-helpers.js" const CREDENTIALS_PATH = "~/.local/share/devin/credentials.toml" const STATE_DB = "~/Library/Application Support/Devin/User/globalStorage/state.vscdb" +const NEXT_STATE_DB = "~/Library/Application Support/Devin - Next/User/globalStorage/state.vscdb" const DEFAULT_API_SERVER_URL = "https://server.codeium.com" const CLOUD_COMPAT_VERSION = "1.108.2" @@ -57,9 +58,8 @@ function makeQuotaResponse(overrides = {}) { function mockAppAuth(ctx, apiKey = "devin-session-token$app") { ctx.host.sqlite.query.mockImplementation((db, sql) => { - expect(db).toBe(STATE_DB) expect(String(sql)).toContain("windsurfAuthStatus") - return makeAuthStatus(apiKey) + return db === STATE_DB ? makeAuthStatus(apiKey) : "[]" }) } @@ -150,6 +150,62 @@ describe("devin plugin", () => { expect(sentBody.metadata.apiKey).toBe("devin-session-token$app") }) + it("reads auth from the Devin - Next app when stable Devin is absent", async () => { + const ctx = makeCtx() + ctx.host.sqlite.query.mockImplementation((db, sql) => { + expect(String(sql)).toContain("windsurfAuthStatus") + if (db === NEXT_STATE_DB) return makeAuthStatus("devin-session-token$next") + return "[]" + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + const queriedDbs = ctx.host.sqlite.query.mock.calls.map(([db]) => db) + expect(queriedDbs).toContain(STATE_DB) + expect(queriedDbs).toContain(NEXT_STATE_DB) + const sentBody = JSON.parse(String(ctx.host.http.request.mock.calls[0][0].bodyText)) + expect(sentBody.metadata.apiKey).toBe("devin-session-token$next") + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("Devin quota diagnostics source=Devin - Next app") + ) + }) + + it("falls back from a stale stable-Devin token to the Devin - Next app", async () => { + const ctx = makeCtx() + ctx.host.sqlite.query.mockImplementation((db, sql) => { + expect(String(sql)).toContain("windsurfAuthStatus") + if (db === STATE_DB) return makeAuthStatus("devin-session-token$stable") + if (db === NEXT_STATE_DB) return makeAuthStatus("devin-session-token$next") + return "[]" + }) + ctx.host.http.request.mockImplementation((request) => { + const body = JSON.parse(String(request.bodyText)) + if (body.metadata.apiKey === "devin-session-token$stable") { + return { status: 401, bodyText: "{}" } + } + return { + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Teams" } })), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Teams") + expect(ctx.host.http.request).toHaveBeenCalledTimes(2) + const triedKeys = ctx.host.http.request.mock.calls.map( + ([request]) => JSON.parse(String(request.bodyText)).metadata.apiKey + ) + expect(triedKeys).toEqual(["devin-session-token$stable", "devin-session-token$next"]) + }) + it("ignores plaintext API server URLs from CLI credentials", async () => { const ctx = makeCtx() ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml({