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.
- package/README.md +100 -0
- package/cordis.patch.yml +6 -0
- package/lib/adapter.js +445 -0
- package/lib/client.js +992 -0
- package/lib/credentials.js +324 -0
- package/lib/errors.js +37 -0
- package/lib/index.js +691 -0
- package/lib/shim.js +378 -0
- package/lib/upstream.js +1232 -0
- package/package.json +71 -0
|
@@ -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 }
|