@magpie-community/opencode-qoder-auth 0.2.8 → 0.2.10

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 +29 -2
  2. package/index.mjs +102 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -186,12 +186,35 @@ 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 sign in to Qoder CLI once, to make its native
204
+ runtime available. The plugin finds the desktop's `resources/umid`
205
+ (`Qoder.app` on macOS, `%LOCALAPPDATA%\Programs\Qoder` on Windows,
206
+ `/opt/Qoder` on Linux) or the runtime the CLI caches in `~/.qoder/.bin`
207
+ (`runtime-info-<platform>-<arch>-*` on macOS and Linux,
208
+ `umid-win32-x64-*\runtime-info.exe` on Windows; `QODER_CONFIG_DIR`,
209
+ `QODER_CLI_HOME` and `QODER_CONFIG_DIR_NAME` move it as they move the
210
+ CLI's folder).
211
+ - For another installation location, set `QODER_RUNTIME_INFO` to the
212
+ absolute path of Qoder's installed `runtime-info` executable
213
+ (`runtime-info.exe` on Windows), with its accompanying SDK files.
214
+ - A missing helper or invalid identity is an error, rather than a false
215
+ "no activity" result. Qoder CN keeps its existing requests and does
216
+ not need this local helper.
217
+
195
218
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
196
219
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
197
220
  `startAt`, `endAt`, `benefit.amount`.
@@ -201,7 +224,11 @@ rotated once and the page asked again.
201
224
  (Qoder CN). Other pages on those hosts are still refused.
202
225
 
203
226
  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.
227
+ which a user reports works, and Qoder desktop 0.4.3's campaign requests.
228
+ International campaign discovery was checked with a real account on
229
+ 2026-10-08: the native identity exposed the claimable 100 credits, and
230
+ omitting either header hid them. This verification only queried the
231
+ activity; a live claim was not performed.
205
232
 
206
233
  ## Not here
207
234
 
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,81 @@ 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
+ //
1041
+ // Where each one keeps it (Qoder desktop 0.4.3's packages, qodercli 1.1.66):
1042
+ // - desktop: <install>/resources/umid/runtime-info(.exe): Qoder.app on
1043
+ // macOS, %LOCALAPPDATA%\Programs\Qoder (the user installer) on Windows,
1044
+ // /opt/Qoder (the .deb and the .rpm) on Linux;
1045
+ // - CLI: <config>/.bin, where <config> is QODER_CONFIG_DIR, else
1046
+ // QODER_CLI_HOME (or the home folder) joined with QODER_CONFIG_DIR_NAME
1047
+ // (or .qoder): a file runtime-info-<platform>-<arch>-<hash> on macOS and
1048
+ // Linux, a folder umid-win32-<arch>-<hash> holding runtime-info.exe on
1049
+ // Windows.
1050
+ async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt" } = {}) {
1051
+ if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
1052
+ const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
1053
+ const isFile = async (path) => (await stat(path).catch(() => null))?.isFile() ?? false
1054
+ const paths = []
1055
+ if (platform === "win32") {
1056
+ const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1057
+ paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1058
+ } else if (platform === "darwin") {
1059
+ for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1060
+ } else if (platform === "linux") {
1061
+ paths.push(join(opt, "Qoder", "resources", "umid", name))
1062
+ }
1063
+ for (const path of paths) if (await isFile(path)) return path
1064
+ const config = env.QODER_CONFIG_DIR || join(env.QODER_CLI_HOME || env.GEMINI_CLI_HOME || home, env.QODER_CONFIG_DIR_NAME || ".qoder")
1065
+ const cache = join(config, ".bin")
1066
+ const entries = await readdir(cache, { withFileTypes: true }).catch(() => [])
1067
+ const found = []
1068
+ for (const d of entries) {
1069
+ let path
1070
+ if (platform === "win32") {
1071
+ if (d.isDirectory() && d.name.startsWith(`umid-win32-${arch}-`)) path = join(cache, d.name, name)
1072
+ } else if (d.isFile() && d.name.startsWith(`runtime-info-${platform}-${arch}-`) && !d.name.endsWith(".tmp")) {
1073
+ path = join(cache, d.name)
1074
+ }
1075
+ if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1076
+ }
1077
+ if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1078
+ throw new Error("Qoder check-in: device identity runtime not found; install Qoder desktop or sign in to Qoder CLI once, or set QODER_RUNTIME_INFO to its runtime-info executable")
1079
+ }
1080
+
1081
+ // Read the SDK identity as Qoder does: environment 3 is international; on
1082
+ // Windows and macOS the uid goes over stdin, never into the command line.
1083
+ async function campaignMachine(uid) {
1084
+ if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
1085
+ const file = await campaignRuntime()
1086
+ const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
1087
+ const text = await new Promise((resolve, reject) => {
1088
+ const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
1089
+ if (err) reject(err)
1090
+ else resolve(out)
1091
+ })
1092
+ child.stdin?.on("error", () => {})
1093
+ child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
1094
+ }).catch(() => {
1095
+ throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
1096
+ })
1097
+ try {
1098
+ const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
1099
+ const headers = {}
1100
+ for (const [key, field] of [["Cosy-MachineToken", "machineToken"], ["Cosy-MachineType", "machineType"]]) {
1101
+ const value = identity?.[field]?.trim()
1102
+ if (!value || value.length > 4096 || !/^[\x20-\x7e]+$/.test(value)) throw new Error("invalid identity")
1103
+ headers[key] = value
1104
+ }
1105
+ return headers
1106
+ } catch {
1107
+ throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
1108
+ }
1109
+ }
1031
1110
 
1032
1111
  // campaignPage: url is the site's campaigns page or a claim under it, which
1033
1112
  // the account's fetch sends as the account rather than as a chat.
@@ -1057,10 +1136,10 @@ function campaignTarget(base, s, url) {
1057
1136
 
1058
1137
  // campaignCall is url asked with the device token; Qoder's answer comes back
1059
1138
  // as it is.
1060
- async function campaignCall(url, method, body, deviceToken, signal) {
1139
+ async function campaignCall(url, method, body, deviceToken, machine, signal) {
1061
1140
  return fetch(url, {
1062
1141
  method,
1063
- headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
1142
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder", ...machine },
1064
1143
  body: method === "GET" ? undefined : body || "{}",
1065
1144
  signal: signal ?? AbortSignal.timeout(20_000),
1066
1145
  })
@@ -1175,6 +1254,23 @@ const SITES = {
1175
1254
 
1176
1255
  const makePlugin = (site) => async ({ client }) => {
1177
1256
  const ID = site.id
1257
+ // The SDK identity is account-specific. Share an in-flight read, refresh
1258
+ // after an hour, and leave a failed read available for the next attempt.
1259
+ const campaignMachines = new Map()
1260
+ const machineForCampaign = async (uid) => {
1261
+ const now = Date.now()
1262
+ let entry = campaignMachines.get(uid)
1263
+ if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
1264
+ entry = { at: now, promise: campaignMachine(uid) }
1265
+ campaignMachines.set(uid, entry)
1266
+ }
1267
+ try {
1268
+ return await entry.promise
1269
+ } catch (e) {
1270
+ if (campaignMachines.get(uid) === entry) campaignMachines.delete(uid)
1271
+ throw e
1272
+ }
1273
+ }
1178
1274
  // serializes checking, rotating and saving tokens: a refresh token is
1179
1275
  // spent once, so two refreshes would spend it twice
1180
1276
  let lock = Promise.resolve()
@@ -1305,8 +1401,9 @@ const makePlugin = (site) => async ({ client }) => {
1305
1401
  if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1306
1402
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1307
1403
  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)
1404
+ const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1405
+ let res = await campaignCall(url, method, body, cred.deviceToken, machine, init.signal)
1406
+ if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1310
1407
  return signed(res, renewed(cred))
1311
1408
  } catch (e) {
1312
1409
  if (e?.expired) return signedInError(e)
@@ -1482,4 +1579,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1482
1579
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1483
1580
 
1484
1581
  // for tests
1485
- export const _internal = { campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
1582
+ 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.10",
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",