@magpie-community/opencode-qoder-auth 0.2.8 → 0.2.9
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/README.md +24 -2
- package/index.mjs +80 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,12 +186,30 @@ The hosts and reply shapes come from a real VPC account's report
|
|
|
186
186
|
|
|
187
187
|
Qoder gives credits for a daily claim (the Qoder client's campaign
|
|
188
188
|
`CLAIM_BENEFIT`, 100 Credits a day as reported), on both sites. magpie can
|
|
189
|
-
|
|
189
|
+
claim it for each account when its Qoder check-in switch is on
|
|
190
190
|
(Settings). It asks these pages through this plugin's fetch (since 0.2.7),
|
|
191
191
|
which sends them as the account, on the device token (`Bearer`,
|
|
192
192
|
`Cosy-ClientType: 10`) as usage is read; a refused device token is
|
|
193
193
|
rotated once and the page asked again.
|
|
194
194
|
|
|
195
|
+
International campaigns also need Qoder's native device identity:
|
|
196
|
+
`Cosy-MachineToken` and `Cosy-MachineType`. Without them, the server can
|
|
197
|
+
return HTTP 200 but omit the daily claim. The plugin reads them from
|
|
198
|
+
Qoder's own `runtime-info` helper for the signed-in uid, sharing an
|
|
199
|
+
in-flight read and caching it in memory for an hour. The same identity
|
|
200
|
+
goes on the list, the claim and a device-token retry; it is not saved in
|
|
201
|
+
the account's credentials.
|
|
202
|
+
|
|
203
|
+
- Install Qoder desktop or run Qoder CLI to make its native runtime
|
|
204
|
+
available. The plugin finds the desktop's standard Windows/macOS
|
|
205
|
+
location or the CLI's matching `~/.qoder/.bin/umid-*` cache.
|
|
206
|
+
- For another installation location, set `QODER_RUNTIME_INFO` to the
|
|
207
|
+
absolute path of Qoder's installed `runtime-info` executable
|
|
208
|
+
(`runtime-info.exe` on Windows), with its accompanying SDK files.
|
|
209
|
+
- A missing helper or invalid identity is an error, rather than a false
|
|
210
|
+
"no activity" result. Qoder CN keeps its existing requests and does
|
|
211
|
+
not need this local helper.
|
|
212
|
+
|
|
195
213
|
- **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
|
|
196
214
|
`campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
|
|
197
215
|
`startAt`, `endAt`, `benefit.amount`.
|
|
@@ -201,7 +219,11 @@ rotated once and the page asked again.
|
|
|
201
219
|
(Qoder CN). Other pages on those hosts are still refused.
|
|
202
220
|
|
|
203
221
|
Taken from [wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin),
|
|
204
|
-
which a user reports works
|
|
222
|
+
which a user reports works, and Qoder desktop 0.4.3's campaign requests.
|
|
223
|
+
International campaign discovery was checked with a real account on
|
|
224
|
+
2026-10-08: the native identity exposed the claimable 100 credits, and
|
|
225
|
+
omitting either header hid them. This verification only queried the
|
|
226
|
+
activity; a live claim was not performed.
|
|
205
227
|
|
|
206
228
|
## Not here
|
|
207
229
|
|
package/index.mjs
CHANGED
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
// <instance>-gateway.vpc.qoder.com.cn. The instance is kept on the account,
|
|
36
36
|
// and an account with none is served as before.
|
|
37
37
|
import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
|
|
38
|
+
import { execFile } from "node:child_process"
|
|
39
|
+
import { readdir, stat } from "node:fs/promises"
|
|
38
40
|
import { STATUS_CODES } from "node:http"
|
|
41
|
+
import { homedir } from "node:os"
|
|
42
|
+
import { join, resolve } from "node:path"
|
|
39
43
|
|
|
40
44
|
const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
|
|
41
45
|
const MODELS_PATH = "/algo/api/v2/model/list?Encode=1"
|
|
@@ -1028,6 +1032,59 @@ async function fetchUsage(site, deviceToken) {
|
|
|
1028
1032
|
// credits among them (actionType CLAIM_BENEFIT) — and claims one
|
|
1029
1033
|
// (POST …/{id}/claim), as Qoder's client does, on the device token.
|
|
1030
1034
|
const CAMPAIGNS = "/sash/api/v1/me/campaigns"
|
|
1035
|
+
const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its native identity hourly
|
|
1036
|
+
|
|
1037
|
+
// campaignRuntime uses Qoder's own native device identity helper, installed
|
|
1038
|
+
// by its desktop client or cached by its CLI. A made-up token (including the
|
|
1039
|
+
// chat envelope's machine id and type 5) doesn't reveal international claims.
|
|
1040
|
+
async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env } = {}) {
|
|
1041
|
+
if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
|
|
1042
|
+
const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
|
|
1043
|
+
const paths = []
|
|
1044
|
+
if (platform === "win32") {
|
|
1045
|
+
const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
|
|
1046
|
+
paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
|
|
1047
|
+
} else if (platform === "darwin") {
|
|
1048
|
+
for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
|
|
1049
|
+
}
|
|
1050
|
+
for (const path of paths) if ((await stat(path).catch(() => null))?.isFile()) return path
|
|
1051
|
+
const cache = join(home, ".qoder", ".bin")
|
|
1052
|
+
const dirs = (await readdir(cache, { withFileTypes: true }).catch(() => []))
|
|
1053
|
+
.filter((d) => d.isDirectory() && d.name.startsWith(`umid-${platform}-${arch}-`))
|
|
1054
|
+
const cached = await Promise.all(dirs.map(async (d) => ({ path: join(cache, d.name, name), time: (await stat(join(cache, d.name))).mtimeMs })))
|
|
1055
|
+
for (const { path } of cached.sort((a, b) => b.time - a.time)) if ((await stat(path).catch(() => null))?.isFile()) return path
|
|
1056
|
+
throw new Error("Qoder check-in: device identity runtime not found; install Qoder desktop or run Qoder CLI, or set QODER_RUNTIME_INFO to its runtime-info executable")
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Read the SDK identity as Qoder does: environment 3 is international; on
|
|
1060
|
+
// Windows and macOS the uid goes over stdin, never into the command line.
|
|
1061
|
+
async function campaignMachine(uid) {
|
|
1062
|
+
if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
|
|
1063
|
+
const file = await campaignRuntime()
|
|
1064
|
+
const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
|
|
1065
|
+
const text = await new Promise((resolve, reject) => {
|
|
1066
|
+
const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
|
|
1067
|
+
if (err) reject(err)
|
|
1068
|
+
else resolve(out)
|
|
1069
|
+
})
|
|
1070
|
+
child.stdin?.on("error", () => {})
|
|
1071
|
+
child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
|
|
1072
|
+
}).catch(() => {
|
|
1073
|
+
throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
|
|
1074
|
+
})
|
|
1075
|
+
try {
|
|
1076
|
+
const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
|
|
1077
|
+
const headers = {}
|
|
1078
|
+
for (const [key, field] of [["Cosy-MachineToken", "machineToken"], ["Cosy-MachineType", "machineType"]]) {
|
|
1079
|
+
const value = identity?.[field]?.trim()
|
|
1080
|
+
if (!value || value.length > 4096 || !/^[\x20-\x7e]+$/.test(value)) throw new Error("invalid identity")
|
|
1081
|
+
headers[key] = value
|
|
1082
|
+
}
|
|
1083
|
+
return headers
|
|
1084
|
+
} catch {
|
|
1085
|
+
throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1031
1088
|
|
|
1032
1089
|
// campaignPage: url is the site's campaigns page or a claim under it, which
|
|
1033
1090
|
// the account's fetch sends as the account rather than as a chat.
|
|
@@ -1057,10 +1114,10 @@ function campaignTarget(base, s, url) {
|
|
|
1057
1114
|
|
|
1058
1115
|
// campaignCall is url asked with the device token; Qoder's answer comes back
|
|
1059
1116
|
// as it is.
|
|
1060
|
-
async function campaignCall(url, method, body, deviceToken, signal) {
|
|
1117
|
+
async function campaignCall(url, method, body, deviceToken, machine, signal) {
|
|
1061
1118
|
return fetch(url, {
|
|
1062
1119
|
method,
|
|
1063
|
-
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
|
|
1120
|
+
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder", ...machine },
|
|
1064
1121
|
body: method === "GET" ? undefined : body || "{}",
|
|
1065
1122
|
signal: signal ?? AbortSignal.timeout(20_000),
|
|
1066
1123
|
})
|
|
@@ -1175,6 +1232,23 @@ const SITES = {
|
|
|
1175
1232
|
|
|
1176
1233
|
const makePlugin = (site) => async ({ client }) => {
|
|
1177
1234
|
const ID = site.id
|
|
1235
|
+
// The SDK identity is account-specific. Share an in-flight read, refresh
|
|
1236
|
+
// after an hour, and leave a failed read available for the next attempt.
|
|
1237
|
+
const campaignMachines = new Map()
|
|
1238
|
+
const machineForCampaign = async (uid) => {
|
|
1239
|
+
const now = Date.now()
|
|
1240
|
+
let entry = campaignMachines.get(uid)
|
|
1241
|
+
if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
|
|
1242
|
+
entry = { at: now, promise: campaignMachine(uid) }
|
|
1243
|
+
campaignMachines.set(uid, entry)
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
return await entry.promise
|
|
1247
|
+
} catch (e) {
|
|
1248
|
+
if (campaignMachines.get(uid) === entry) campaignMachines.delete(uid)
|
|
1249
|
+
throw e
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1178
1252
|
// serializes checking, rotating and saving tokens: a refresh token is
|
|
1179
1253
|
// spent once, so two refreshes would spend it twice
|
|
1180
1254
|
let lock = Promise.resolve()
|
|
@@ -1305,8 +1379,9 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1305
1379
|
if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
|
|
1306
1380
|
const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
|
|
1307
1381
|
try {
|
|
1308
|
-
|
|
1309
|
-
|
|
1382
|
+
const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
|
|
1383
|
+
let res = await campaignCall(url, method, body, cred.deviceToken, machine, init.signal)
|
|
1384
|
+
if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
|
|
1310
1385
|
return signed(res, renewed(cred))
|
|
1311
1386
|
} catch (e) {
|
|
1312
1387
|
if (e?.expired) return signedInError(e)
|
|
@@ -1482,4 +1557,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
|
|
|
1482
1557
|
export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
|
|
1483
1558
|
|
|
1484
1559
|
// for tests
|
|
1485
|
-
export const _internal = { campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
|
|
1560
|
+
export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
|
package/package.json
CHANGED