@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.
Files changed (3) hide show
  1. package/README.md +24 -2
  2. package/index.mjs +80 -5
  3. 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
- press it once a day for each account when its Qoder check-in switch is on
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. Not yet tried here with a real Qoder account.
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
- let res = await campaignCall(url, method, body, cred.deviceToken, init.signal)
1309
- if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), init.signal)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",