dsh-connect-qoder 0.1.1 → 0.2.0

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 (2) hide show
  1. package/lib/credentials.js +89 -5
  2. package/package.json +1 -1
@@ -21,7 +21,7 @@
21
21
  import { execFileSync } from 'node:child_process'
22
22
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
23
23
  import { tmpdir } from 'node:os'
24
- import { join } from 'node:path'
24
+ import { join, basename } from 'node:path'
25
25
  import { createDecipheriv } from 'node:crypto'
26
26
  import { DatabaseSync } from 'node:sqlite'
27
27
 
@@ -49,6 +49,11 @@ export const REGIONS = [
49
49
  mode: 'cn',
50
50
  displayName: 'Qoder CN',
51
51
  appNames: ['QoderCN', 'Qoder CN', 'QoderWork CN'],
52
+ // Qoder 0.3.x moved its user data to a `com.<vendor>.app.<channel>` Electron
53
+ // directory and replaced the VS Code `state.vscdb` store with `auth.v1.dat`.
54
+ // Both layouts are listed so a user who upgrades keeps working; the newer
55
+ // one is tried first because that is where a freshly installed app writes.
56
+ newAppNames: ['com.qodercn.app.stable'],
52
57
  baseUrl: 'https://gateway.qoder.com.cn/',
53
58
  openApiUrl: 'https://openapi.qoder.com.cn',
54
59
  centerUrl: 'https://gateway.qoder.com.cn',
@@ -60,6 +65,7 @@ export const REGIONS = [
60
65
  mode: 'global',
61
66
  displayName: 'Qoder',
62
67
  appNames: ['Qoder', 'QoderWork'],
68
+ newAppNames: ['com.qoder.app.stable'],
63
69
  baseUrl: 'https://api3.qoder.sh/',
64
70
  openApiUrl: 'https://openapi.qoder.sh',
65
71
  centerUrl: 'https://center.qoder.sh',
@@ -218,11 +224,14 @@ function stateDbCandidates(appDir) {
218
224
  /**
219
225
  * The machine id Qoder binds its session to.
220
226
  *
221
- * `machineid` sits beside `Local State`; when it is missing a stable value is
222
- * derived from the app directory so repeated runs still agree with each other.
227
+ * The legacy layout keeps it in `machineid`; 0.3.x renamed the file to
228
+ * `auth.machine-id`. Both are checked because the id is sent to the gateway
229
+ * alongside the token, and a wrong one is indistinguishable from a hijacked
230
+ * session. When neither exists a stable value is derived from the app directory
231
+ * so repeated runs still agree with each other.
223
232
  */
224
233
  function machineIdFor(appDir, fallback) {
225
- for (const name of ['machineid', 'machineId']) {
234
+ for (const name of ['auth.machine-id', 'machineid', 'machineId']) {
226
235
  const p = join(appDir, name)
227
236
  if (!existsSync(p)) continue
228
237
  const value = readFileSync(p, 'utf8').trim()
@@ -231,16 +240,91 @@ function machineIdFor(appDir, fallback) {
231
240
  return fallback
232
241
  }
233
242
 
243
+ /**
244
+ * Read the 0.3.x credential file.
245
+ *
246
+ * From 0.3.x the apps keep their sign-in in
247
+ * `<userData>/auth.v1.dat` — a Chromium OSCrypt blob whose plaintext is the
248
+ * session JSON directly, rather than a row inside a VS Code `state.vscdb`:
249
+ *
250
+ * ```json
251
+ * { "schemaVersion": 1, "token": "dt-…", "refreshToken": "drt-…",
252
+ * "expiresAt": "2026-10-19T07:44:22Z", "refreshTokenExpiresAt": "…",
253
+ * "user": { "id": "…", "name": "…", "email": "…" } }
254
+ * ```
255
+ *
256
+ * The envelope is unchanged (`v10` + AES-256-GCM under the DPAPI-wrapped key),
257
+ * so {@link oscryptKeyFor} and {@link decryptOscrypt} are reused as they are.
258
+ *
259
+ * @returns a credential record, or `undefined` when the file is absent or
260
+ * cannot be decoded.
261
+ */
262
+ function loadNewCredential(region, appDir, oscryptKey) {
263
+ const file = join(appDir, 'auth.v1.dat')
264
+ if (!existsSync(file)) return undefined
265
+ const plain = decryptOscrypt(readFileSync(file), oscryptKey)
266
+ if (plain === undefined) return undefined
267
+ let session
268
+ try {
269
+ session = JSON.parse(plain)
270
+ } catch {
271
+ return undefined
272
+ }
273
+ if (session === null || typeof session !== 'object') return undefined
274
+ if (typeof session.token !== 'string' || session.token.length === 0) return undefined
275
+ const user = session.user !== null && typeof session.user === 'object' ? session.user : {}
276
+ const userID = typeof user.id === 'string' ? user.id : ''
277
+ if (userID.length === 0) return undefined
278
+ // The new file carries ISO timestamps rather than epoch millis.
279
+ const expiresAt = Date.parse(session.expiresAt)
280
+ const refreshExpiresAt = Date.parse(session.refreshTokenExpiresAt)
281
+ return {
282
+ region: region.id,
283
+ appName: basename(appDir),
284
+ userID,
285
+ name: typeof user.name === 'string' ? user.name : '',
286
+ email: typeof user.email === 'string' ? user.email : '',
287
+ token: session.token,
288
+ refreshToken: typeof session.refreshToken === 'string' ? session.refreshToken : '',
289
+ refreshTokenExpiresAt: Number.isFinite(refreshExpiresAt) ? refreshExpiresAt : 0,
290
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
291
+ expired: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt <= Date.now() : false,
292
+ // The new layout does not publish these; they were only ever used for
293
+ // display, and the catalog call supplies what routing actually needs.
294
+ userType: '',
295
+ userTag: '',
296
+ machineID: machineIdFor(appDir, `dsh-connect-qoder-${region.id}`),
297
+ source: 'app',
298
+ plan: undefined,
299
+ usage: undefined,
300
+ }
301
+ }
302
+
234
303
  /**
235
304
  * Load one region's credential from the local Qoder apps.
236
305
  *
306
+ * The 0.3.x layout is tried before the legacy one, because that is where a
307
+ * freshly installed app writes; a user who upgrades therefore keeps working
308
+ * without a reinstall, and a user who has not upgraded is unaffected.
309
+ *
237
310
  * Every candidate app is tried in order; the first that yields a decryptable
238
- * `userInfo` row wins. A credential whose access token has expired is still
311
+ * credential wins. A credential whose access token has expired is still
239
312
  * returned — the caller decides whether to refresh it — but `expired` says so.
240
313
  *
241
314
  * @returns a credential record, or `undefined` when no app holds a usable one.
242
315
  */
243
316
  export function loadCredential(region, appDataRoot) {
317
+ // 0.3.x: `com.<vendor>.app.stable/auth.v1.dat`.
318
+ for (const appName of region.newAppNames ?? []) {
319
+ const appDir = join(appDataRoot, appName)
320
+ if (!existsSync(appDir)) continue
321
+ const oscryptKey = oscryptKeyFor(appDir)
322
+ if (oscryptKey === undefined) continue
323
+ const credential = safeRead(() => loadNewCredential(region, appDir, oscryptKey))
324
+ if (credential !== undefined) return credential
325
+ }
326
+
327
+ // Legacy: `<AppName>/User/globalStorage/state.vscdb`.
244
328
  for (const appName of region.appNames) {
245
329
  const appDir = join(appDataRoot, appName)
246
330
  if (!existsSync(appDir)) continue
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-qoder",
3
3
  "displayName": "DSH Connect Qoder",
4
4
  "description": "将本机已登录的 Qoder(国内版 Qoder CN / 国际版 Qoder)模型接入 DeepSeek Harness —— bring locally signed-in Qoder models into DeepSeek Harness with zero configuration.",
5
- "version": "0.1.1",
5
+ "version": "0.2.0",
6
6
  "author": "hdhgsysh",
7
7
  "homepage": "https://github.com/hdhgsysh/dsh-connect-qoder",
8
8
  "repository": {