Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
59 changes: 46 additions & 13 deletions plugins/devin/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,10 +137,19 @@
}
}

function loadAppAuth(ctx) {
function loadAppAuths(ctx) {
var auths = []
for (var i = 0; i < APP_AUTH_SOURCES.length; i++) {
var auth = readAppAuth(ctx, APP_AUTH_SOURCES[i])
if (auth) auths.push(auth)
}
return auths
}

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)
Expand All @@ -141,10 +159,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
}
}
Expand Down Expand Up @@ -278,23 +296,26 @@
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.
var appAuths = loadAppAuths(ctx)
for (var i = 0; i < appAuths.length; i++) {
var appAuth = appAuths[i]
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
Expand All @@ -305,5 +326,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 }
})()
60 changes: 58 additions & 2 deletions plugins/devin/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) : "[]"
})
}

Expand Down Expand Up @@ -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({
Expand Down
Loading