dsh-connect-qoder 0.1.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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Qoder credential acquisition.
3
+ *
4
+ * The Qoder desktop apps (Qoder CN, Qoder, QoderWork CN) keep their sign-in in
5
+ * a VS Code style SQLite store (`state.vscdb`) whose secret rows are Chromium
6
+ * OSCrypt blobs: `"v10" || nonce(12) || ciphertext || tag(16)`, encrypted with
7
+ * an AES-256-GCM key that is itself wrapped by the OS keystore and kept in the
8
+ * app's `Local State` under `os_crypt.encrypted_key`.
9
+ *
10
+ * On Windows that wrapper is DPAPI scoped to the current user, which is why any
11
+ * process running as the same user can unwrap it — that is the property this
12
+ * module relies on. The unwrap is delegated to PowerShell because Node has no
13
+ * built-in DPAPI binding, and the result is exchanged through a temp file
14
+ * rather than a pipe so the call also works under a sandbox that forbids
15
+ * piped stdio.
16
+ *
17
+ * Nothing here writes to the Qoder apps' files: the store is opened read-only.
18
+ *
19
+ * @module dsh-connect-qoder/credentials
20
+ */
21
+ import { execFileSync } from 'node:child_process'
22
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { createDecipheriv } from 'node:crypto'
26
+ import { DatabaseSync } from 'node:sqlite'
27
+
28
+ /** SQLite key holding the signed-in identity, including its access token. */
29
+ const USER_INFO_KEY = 'secret://aicoding.auth.userInfo'
30
+ /** SQLite key holding the plan summary (tier, validity window). */
31
+ const USER_PLAN_KEY = 'secret://aicoding.auth.userPlan'
32
+ /** SQLite key holding the credit/quota snapshot. */
33
+ const CREDIT_USAGE_KEY = 'secret://aicoding.auth.creditUsage'
34
+
35
+ /**
36
+ * One Qoder region.
37
+ *
38
+ * `providerId` is the DSH provider route this region registers. The CN and
39
+ * global regions are separate providers so both can be online at once, exactly
40
+ * as the Trae bundle does for its two editions.
41
+ *
42
+ * `appDirs` is the ordered list of Electron user-data directory names that may
43
+ * hold this region's sign-in; the first one that yields a readable credential
44
+ * wins. `appNames` is the matching list of `%APPDATA%` roots.
45
+ */
46
+ export const REGIONS = [
47
+ {
48
+ id: 'qoder-cn',
49
+ mode: 'cn',
50
+ displayName: 'Qoder CN',
51
+ appNames: ['QoderCN', 'Qoder CN', 'QoderWork CN'],
52
+ baseUrl: 'https://gateway.qoder.com.cn/',
53
+ openApiUrl: 'https://openapi.qoder.com.cn',
54
+ centerUrl: 'https://gateway.qoder.com.cn',
55
+ manageUrl: 'https://qoder.com.cn',
56
+ patEnvNames: ['QODERCN_API_KEY', 'QODERCN_PERSONAL_ACCESS_TOKEN', 'QODERCN_PAT'],
57
+ },
58
+ {
59
+ id: 'qoder',
60
+ mode: 'global',
61
+ displayName: 'Qoder',
62
+ appNames: ['Qoder', 'QoderWork'],
63
+ baseUrl: 'https://api3.qoder.sh/',
64
+ openApiUrl: 'https://openapi.qoder.sh',
65
+ centerUrl: 'https://center.qoder.sh',
66
+ manageUrl: 'https://qoder.com',
67
+ patEnvNames: ['QODER_API_KEY', 'QODER_PERSONAL_ACCESS_TOKEN', 'QODER_PAT'],
68
+ },
69
+ ]
70
+
71
+ /** PowerShell that unwraps the OSCrypt key and writes it to `$env:QODER_KEY_OUT`. */
72
+ const DPAPI_SCRIPT = `
73
+ $ErrorActionPreference = 'Stop'
74
+ Add-Type -TypeDefinition @'
75
+ using System;
76
+ using System.Runtime.InteropServices;
77
+ public static class QoderDpapi {
78
+ [StructLayout(LayoutKind.Sequential)] public struct B { public int cbData; public IntPtr pbData; }
79
+ [DllImport("Crypt32.dll", SetLastError=true)]
80
+ static extern bool CryptUnprotectData(ref B i, IntPtr d, IntPtr e, IntPtr r, IntPtr p, int f, ref B o);
81
+ [DllImport("Kernel32.dll")] static extern IntPtr LocalFree(IntPtr h);
82
+ public static byte[] U(byte[] data) {
83
+ B i = new B(); i.cbData = data.Length; i.pbData = Marshal.AllocHGlobal(data.Length);
84
+ Marshal.Copy(data, 0, i.pbData, data.Length);
85
+ B o = new B();
86
+ try {
87
+ if (!CryptUnprotectData(ref i, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 0, ref o))
88
+ throw new Exception("DPAPI error " + Marshal.GetLastWin32Error());
89
+ byte[] r = new byte[o.cbData]; Marshal.Copy(o.pbData, r, 0, o.cbData); return r;
90
+ } finally { Marshal.FreeHGlobal(i.pbData); if (o.pbData != IntPtr.Zero) LocalFree(o.pbData); }
91
+ }
92
+ }
93
+ '@
94
+ $statePath = Join-Path $env:QODER_APP_DIR 'Local State'
95
+ $json = Get-Content $statePath -Raw | ConvertFrom-Json
96
+ $raw = [Convert]::FromBase64String($json.os_crypt.encrypted_key)
97
+ if ($raw.Length -le 5) { throw 'encrypted_key too short' }
98
+ $key = [QoderDpapi]::U($raw[5..($raw.Length - 1)])
99
+ [System.IO.File]::WriteAllText($env:QODER_KEY_OUT, [Convert]::ToBase64String($key))
100
+ `
101
+
102
+ /** Per-app OSCrypt key cache; the DPAPI unwrap is not free, so do it once. */
103
+ const keyCache = new Map()
104
+
105
+ /**
106
+ * Unwrap one app's OSCrypt key.
107
+ *
108
+ * @param appDir - absolute Electron user-data directory for the app.
109
+ * @returns the 32-byte AES key, or `undefined` when it cannot be obtained.
110
+ */
111
+ export function oscryptKeyFor(appDir) {
112
+ if (keyCache.has(appDir)) return keyCache.get(appDir)
113
+ let key
114
+ const statePath = join(appDir, 'Local State')
115
+ if (existsSync(statePath)) {
116
+ let dir
117
+ try {
118
+ dir = mkdtempSync(join(tmpdir(), 'qoder-oscrypt-'))
119
+ const outFile = join(dir, 'key.b64')
120
+ execFileSync(
121
+ 'powershell.exe',
122
+ ['-NoProfile', '-NonInteractive', '-Command', DPAPI_SCRIPT],
123
+ {
124
+ stdio: 'ignore',
125
+ windowsHide: true,
126
+ timeout: 30000,
127
+ env: { ...process.env, QODER_APP_DIR: appDir, QODER_KEY_OUT: outFile },
128
+ },
129
+ )
130
+ const text = readFileSync(outFile, 'utf8').trim()
131
+ if (text.length > 0) {
132
+ const candidate = Buffer.from(text, 'base64')
133
+ if (candidate.length === 32) key = candidate
134
+ }
135
+ } catch {
136
+ key = undefined
137
+ } finally {
138
+ if (dir !== undefined) rmSync(dir, { recursive: true, force: true })
139
+ }
140
+ }
141
+ keyCache.set(appDir, key)
142
+ return key
143
+ }
144
+
145
+ /**
146
+ * Decrypt one Chromium OSCrypt blob.
147
+ *
148
+ * @param blob - the raw stored bytes, including the `v10` prefix.
149
+ * @param key - the 32-byte AES key from {@link oscryptKeyFor}.
150
+ * @returns the plaintext, or `undefined` when authentication fails.
151
+ */
152
+ export function decryptOscrypt(blob, key) {
153
+ if (blob.length < 3 + 12 + 16) return undefined
154
+ if (blob.subarray(0, 3).toString('latin1') !== 'v10') return undefined
155
+ const body = blob.subarray(3)
156
+ const nonce = body.subarray(0, 12)
157
+ const tag = body.subarray(body.length - 16)
158
+ const ciphertext = body.subarray(12, body.length - 16)
159
+ try {
160
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce)
161
+ decipher.setAuthTag(tag)
162
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
163
+ } catch {
164
+ return undefined
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Read one secret row from a VS Code style `state.vscdb`.
170
+ *
171
+ * The value column is a JSON envelope (`{"type":"Buffer","data":[...]}`) for
172
+ * secret rows; anything else is returned as-is.
173
+ *
174
+ * @returns the raw bytes for a Buffer row, a string for a plain row, or
175
+ * `undefined` when the key is absent.
176
+ */
177
+ function readItem(dbPath, key) {
178
+ const db = new DatabaseSync(dbPath, { readOnly: true })
179
+ try {
180
+ const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key)
181
+ if (row === undefined || row.value === null || row.value === undefined) return undefined
182
+ const value = row.value
183
+ const text = value instanceof Uint8Array ? Buffer.from(value).toString('utf8') : String(value)
184
+ if (text.startsWith('{') && text.includes('"type":"Buffer"')) {
185
+ try {
186
+ return Buffer.from(JSON.parse(text).data)
187
+ } catch {
188
+ return text
189
+ }
190
+ }
191
+ return text
192
+ } finally {
193
+ db.close()
194
+ }
195
+ }
196
+
197
+ /** Decode one JSON secret row, or `undefined` when absent/undecryptable. */
198
+ function readJsonSecret(dbPath, key, oscryptKey) {
199
+ const raw = readItem(dbPath, key)
200
+ if (raw === undefined || typeof raw === 'string') return undefined
201
+ const plain = decryptOscrypt(raw, oscryptKey)
202
+ if (plain === undefined) return undefined
203
+ try {
204
+ return JSON.parse(plain)
205
+ } catch {
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ /** Candidate `state.vscdb` paths for one app's Electron user-data directory. */
211
+ function stateDbCandidates(appDir) {
212
+ return [
213
+ join(appDir, 'User', 'globalStorage', 'state.vscdb'),
214
+ join(appDir, 'User', 'globalStorage', 'state.vscdb.backup'),
215
+ ]
216
+ }
217
+
218
+ /**
219
+ * The machine id Qoder binds its session to.
220
+ *
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.
223
+ */
224
+ function machineIdFor(appDir, fallback) {
225
+ for (const name of ['machineid', 'machineId']) {
226
+ const p = join(appDir, name)
227
+ if (!existsSync(p)) continue
228
+ const value = readFileSync(p, 'utf8').trim()
229
+ if (value.length > 0) return value
230
+ }
231
+ return fallback
232
+ }
233
+
234
+ /**
235
+ * Load one region's credential from the local Qoder apps.
236
+ *
237
+ * 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
239
+ * returned — the caller decides whether to refresh it — but `expired` says so.
240
+ *
241
+ * @returns a credential record, or `undefined` when no app holds a usable one.
242
+ */
243
+ export function loadCredential(region, appDataRoot) {
244
+ for (const appName of region.appNames) {
245
+ const appDir = join(appDataRoot, appName)
246
+ if (!existsSync(appDir)) continue
247
+ const oscryptKey = oscryptKeyFor(appDir)
248
+ if (oscryptKey === undefined) continue
249
+ for (const dbPath of stateDbCandidates(appDir)) {
250
+ if (!existsSync(dbPath)) continue
251
+ let userInfo
252
+ try {
253
+ userInfo = readJsonSecret(dbPath, USER_INFO_KEY, oscryptKey)
254
+ } catch {
255
+ continue
256
+ }
257
+ if (userInfo === undefined || typeof userInfo.token !== 'string' || userInfo.token.length === 0) continue
258
+ if (typeof userInfo.id !== 'string' || userInfo.id.length === 0) continue
259
+ const expiresAt = Number(userInfo.expireTime)
260
+ return {
261
+ region: region.id,
262
+ appName,
263
+ userID: userInfo.id,
264
+ name: typeof userInfo.name === 'string' ? userInfo.name : '',
265
+ email: typeof userInfo.email === 'string' ? userInfo.email : '',
266
+ token: userInfo.token,
267
+ refreshToken: typeof userInfo.refreshToken === 'string' ? userInfo.refreshToken : '',
268
+ refreshTokenExpiresAt: Number(userInfo.refreshTokenExpireTime) || 0,
269
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
270
+ expired: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt <= Date.now() : false,
271
+ userType: typeof userInfo.userType === 'string' ? userInfo.userType : '',
272
+ userTag: typeof userInfo.userTag === 'string' ? userInfo.userTag : '',
273
+ machineID: machineIdFor(appDir, `dsh-connect-qoder-${region.id}`),
274
+ source: 'app',
275
+ plan: safeRead(() => readJsonSecret(dbPath, USER_PLAN_KEY, oscryptKey)),
276
+ usage: safeRead(() => readJsonSecret(dbPath, CREDIT_USAGE_KEY, oscryptKey)),
277
+ }
278
+ }
279
+ }
280
+ return undefined
281
+ }
282
+
283
+ /** Run a reader, mapping any failure to `undefined`. */
284
+ function safeRead(fn) {
285
+ try {
286
+ return fn()
287
+ } catch {
288
+ return undefined
289
+ }
290
+ }
291
+
292
+ /**
293
+ * A credential supplied through the environment instead of the desktop app.
294
+ *
295
+ * The Qoder personal access token is the officially documented integration
296
+ * path, so it is honoured as a fallback when no app sign-in is present. The
297
+ * token is exchanged for a job token by {@link module:dsh-connect-qoder/upstream}.
298
+ */
299
+ export function loadEnvCredential(region, env = process.env) {
300
+ for (const name of region.patEnvNames) {
301
+ const value = env[name]
302
+ if (typeof value === 'string' && value.trim().length > 0) {
303
+ return {
304
+ region: region.id,
305
+ appName: name,
306
+ userID: '',
307
+ name: '',
308
+ email: '',
309
+ token: value.trim(),
310
+ refreshToken: '',
311
+ refreshTokenExpiresAt: 0,
312
+ expiresAt: 0,
313
+ expired: false,
314
+ userType: '',
315
+ userTag: '',
316
+ machineID: `dsh-connect-qoder-${region.id}`,
317
+ source: 'env-pat',
318
+ plan: undefined,
319
+ usage: undefined,
320
+ }
321
+ }
322
+ }
323
+ return undefined
324
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read a Qoder error frame and decide what it actually is.
3
+ *
4
+ * Two codes arrive here with very different meanings, and conflating them sent
5
+ * the user looking at their account for what is really a queue:
6
+ *
7
+ * - **105 / TOKEN_EXPIRE** — the sign-in is gone. Nothing will succeed until
8
+ * the user signs in to the Qoder app again.
9
+ * - **10605** — the request was queued or rate-limited. The body carries
10
+ * `retryAfterSeconds` and `serviceAvailable`, so it is transient and must be
11
+ * treated as retryable rather than as a rejection.
12
+ */
13
+ function classifyUpstreamError(chunk, code, detail) {
14
+ // 10605 arrives as a JSON *string* in `message`, with a queue payload.
15
+ const text = String(detail ?? '')
16
+ if (code === '10605' || /"queueType"|"retryAfterSeconds"|"isQueued"/.test(text)) {
17
+ let seconds = 0
18
+ try {
19
+ const parsed = JSON.parse(text.startsWith('{') ? text : JSON.parse(`"${text}"`))
20
+ if (typeof parsed === 'string') {
21
+ const inner = JSON.parse(parsed)
22
+ seconds = Number(inner?.retryAfterSeconds) || 0
23
+ } else {
24
+ seconds = Number(parsed?.retryAfterSeconds) || 0
25
+ }
26
+ } catch {
27
+ // A queue error whose body will not parse is still a queue error.
28
+ }
29
+ return { kind: 'rate-limit', retryAfterSeconds: seconds }
30
+ }
31
+ if (/Login expired|TOKEN_EXPIRE|token is not active/i.test(text) || code === '105') {
32
+ return { kind: 'sign-in-expired' }
33
+ }
34
+ return { kind: 'upstream' }
35
+ }
36
+
37
+ export { classifyUpstreamError }