zcode-qoder-connect 0.0.0-stage → 1.0.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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/bin/cli.mjs +204 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/hooks/service-ensure.mjs +115 -0
- package/package.json +50 -4
- package/skills/qoder-models/SKILL.md +68 -0
- package/src/catalog.js +58 -0
- package/src/config.js +81 -0
- package/src/credentials.js +185 -0
- package/src/loopback.js +40 -0
- package/src/server.js +799 -0
- package/src/upstream.js +581 -0
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zcode-qoder-connect",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bring Qoder CN models (DeepSeek-V4-Pro, Qwen3.8-Max, GLM-5.3 …) into ZCode as an OpenAI-compatible provider, reusing the Qoder desktop sign-in — zero configuration.",
|
|
5
|
+
"description_i18n": {
|
|
6
|
+
"en": "Bring Qoder CN models into ZCode as an OpenAI-compatible provider, reusing the Qoder desktop sign-in.",
|
|
7
|
+
"zh-CN": "把 Qoder 国内版的模型(DeepSeek-V4-Pro、Qwen3.8-Max、GLM-5.3 等)复用桌面登录零配置接入 ZCode 的模型供应商。"
|
|
8
|
+
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "xiao-hans",
|
|
11
|
+
"url": "https://github.com/xiao-hans"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/xiao-hans/zcode-ai-connect.git",
|
|
18
|
+
"directory": "zcode-qoder-connect"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/xiao-hans/zcode-ai-connect/tree/main/zcode-qoder-connect#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/xiao-hans/zcode-ai-connect/issues"
|
|
23
|
+
},
|
|
24
|
+
"os": [
|
|
25
|
+
"win32"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.17.0"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"zcode-qoder-connect": "bin/cli.mjs"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
".zcode-plugin",
|
|
35
|
+
"bin",
|
|
36
|
+
"src",
|
|
37
|
+
"hooks",
|
|
38
|
+
"skills",
|
|
39
|
+
"docs",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"qoder",
|
|
46
|
+
"openai-compatible",
|
|
47
|
+
"provider",
|
|
48
|
+
"deepseek",
|
|
49
|
+
"qwen",
|
|
50
|
+
"glm"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qoder-models
|
|
3
|
+
description: Operate the Qoder model provider — start/stop the local endpoint, check credential and quota, list or refresh the model roster, run daily check-in, and troubleshoot "model fails to respond" errors. Use when the user asks about Qoder models in ZCode, the provider shows no models, a Qoder model errors out, quota balance, or the check-in credits.
|
|
4
|
+
when_to_use: The user mentions Qoder, the Qoder provider, or a Qoder model (DeepSeek-V4-Pro, DeepSeek-Flash, Qwen3.8-Max, Qwen3.8-Flash, GLM-5.3, Fast, Auto), or reports that a model from this provider fails.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Qoder models in ZCode
|
|
8
|
+
|
|
9
|
+
This plugin exposes the models inside the **Qoder CN desktop app** as a ZCode
|
|
10
|
+
model provider. Credentials are reused read-only from the Qoder desktop's own
|
|
11
|
+
sign-in vault (DPAPI + AES-GCM `auth.v1.dat`); a PAT
|
|
12
|
+
(`qoder.cn → 账号设置 → 个人访问令牌`) is an optional fallback. A small local
|
|
13
|
+
service translates between OpenAI chat-completions and Qoder's COSY-signed
|
|
14
|
+
gateway protocol:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
ZCode ──(OpenAI chat-completions)──▶ 127.0.0.1:39270 ──▶ gateway.qoder.com.cn
|
|
18
|
+
local endpoint (COSY + WAF encode)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Where things are
|
|
22
|
+
|
|
23
|
+
| Thing | Path |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Service + CLI | `${ZCODE_PLUGIN_ROOT}` |
|
|
26
|
+
| State (PAT, endpoint bearer, logs) | `%USERPROFILE%\.zcode-qoder-connect` |
|
|
27
|
+
| Provider entry (registry the picker reads) | `%USERPROFILE%\.zcode\v2\provider_config.json` |
|
|
28
|
+
| Qoder desktop vault (read-only) | `%APPDATA%\com.qodercn.app.stable\auth.v1.dat` |
|
|
29
|
+
|
|
30
|
+
All CLI commands are run as `node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" <command>`.
|
|
31
|
+
|
|
32
|
+
## The one thing that usually matters
|
|
33
|
+
|
|
34
|
+
The provider only works while the local endpoint is running. A `SessionStart`
|
|
35
|
+
hook starts it automatically, but if a session reports "model not responding":
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" service-status
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **`endpoint … up`** — the service is fine; look elsewhere (quota, credential).
|
|
42
|
+
- **`endpoint … NOT reachable`** — start it:
|
|
43
|
+
`node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" serve --port 39270`
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `serve [--port N]` | Run the endpoint (default 39270) |
|
|
50
|
+
| `status` | Credential tracks (desktop vault / PAT), quota, model roster |
|
|
51
|
+
| `refresh` | Re-pull the model roster from the gateway |
|
|
52
|
+
| `checkin` | Claim today's Qoder check-in credits when a campaign is live |
|
|
53
|
+
| `login <PAT>` | Validate and save a personal access token |
|
|
54
|
+
| `logout` | Drop the saved PAT (desktop vault untouched) |
|
|
55
|
+
| `service-status` | Is the endpoint reachable |
|
|
56
|
+
|
|
57
|
+
## Diagnosing a failure
|
|
58
|
+
|
|
59
|
+
1. **Is the endpoint up?** `service-status`. If not reachable, start it.
|
|
60
|
+
2. **Is the desktop dt- token fresh?** `status`. When it reads `dt- 已过期`,
|
|
61
|
+
the fix is opening the Qoder CN desktop app ONCE so it refreshes the vault
|
|
62
|
+
(the plugin re-reads it automatically; no re-login needed on this side).
|
|
63
|
+
3. **Quota left?** `status` prints the credits document
|
|
64
|
+
(`isQuotaExceeded`, `totalUsagePercentage`).
|
|
65
|
+
4. **Free-tier models queue at peak** — the service retries a queued request
|
|
66
|
+
(code 10605) for ~24s; a 429-ish failure right after usually means the queue
|
|
67
|
+
was longer still. Retry, or switch to a paid-tier model.
|
|
68
|
+
5. **Model roster changed?** `refresh` then `status`.
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder model catalog: the roster the endpoint publishes.
|
|
3
|
+
*
|
|
4
|
+
* Content lives in the upstream module (FALLBACK_MODELS plus the live roster
|
|
5
|
+
* from the Qoder model list); this holder adds the two things the endpoint
|
|
6
|
+
* needs on top of it — visibility and provenance. Visibility is separate from
|
|
7
|
+
* content because a signed-out account must expose *no* models: serving the
|
|
8
|
+
* fallback roster to a signed-out user offers models that can only fail, which
|
|
9
|
+
* is worse than showing nothing.
|
|
10
|
+
*
|
|
11
|
+
* @module zcode-qoder-connect/catalog
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { FALLBACK_MODELS } from './upstream.js'
|
|
15
|
+
|
|
16
|
+
/** The roster the endpoint serves before its first successful fetch. */
|
|
17
|
+
function initialRoster() {
|
|
18
|
+
return [...FALLBACK_MODELS]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class QoderCatalog {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.models = initialRoster()
|
|
24
|
+
this.visible = false
|
|
25
|
+
/** Provenance of the roster currently held. */
|
|
26
|
+
this.source = 'fallback'
|
|
27
|
+
this.fetchedAtMs = undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Current entries; empty while the catalog has no usable credential. */
|
|
31
|
+
current() {
|
|
32
|
+
if (!this.visible) return []
|
|
33
|
+
return this.models
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Replace the list with a freshly fetched roster. */
|
|
37
|
+
set(models, { source, fetchedAtMs } = {}) {
|
|
38
|
+
this.models = [...models]
|
|
39
|
+
if (source !== undefined) this.source = source
|
|
40
|
+
this.fetchedAtMs = fetchedAtMs ?? Date.now()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isVisible() {
|
|
44
|
+
return this.visible
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Show or hide the whole catalog. Returns whether the value changed. */
|
|
48
|
+
setVisible(visible) {
|
|
49
|
+
if (this.visible === visible) return false
|
|
50
|
+
this.visible = visible
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Models to fall back to when the upstream fetch fails; ignores visibility. */
|
|
55
|
+
fallback() {
|
|
56
|
+
return this.models
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime paths shared across the Qoder connect service.
|
|
3
|
+
*
|
|
4
|
+
* Vendor-agnostic shape copied from zcode-trae-connect; only the env var and
|
|
5
|
+
* state-directory name are Qoder-specific so the credential copy and the
|
|
6
|
+
* endpoint bearer live under their own folder, never inside the Qoder desktop
|
|
7
|
+
* app's own files.
|
|
8
|
+
*
|
|
9
|
+
* @module zcode-qoder-connect/config
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Root of everything this service owns on disk.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately outside the Qoder app's data dir: the credential copy and the
|
|
19
|
+
* endpoint bearer are this service's state.
|
|
20
|
+
*/
|
|
21
|
+
export function defaultStateDir() {
|
|
22
|
+
const fromEnv = process.env['ZCODE_QODER_CONNECT_HOME']
|
|
23
|
+
if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv
|
|
24
|
+
return join(homedir(), '.zcode-qoder-connect')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const state = {
|
|
28
|
+
stateDir: defaultStateDir(),
|
|
29
|
+
/** Fixed loopback port; 0 asks the OS for a free port. */
|
|
30
|
+
port: 0,
|
|
31
|
+
/** Bearer the OpenAI-compatible endpoint requires. */
|
|
32
|
+
token: '',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Point the service's state (credential copy, caches) at a directory. */
|
|
36
|
+
export function configureStateDir(dir) {
|
|
37
|
+
if (typeof dir === 'string' && dir !== '') state.stateDir = dir
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function stateDir() {
|
|
41
|
+
return state.stateDir
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function configureEndpoint({ port, token } = {}) {
|
|
45
|
+
if (typeof port === 'number' && Number.isInteger(port) && port >= 0 && port <= 65535) state.port = port
|
|
46
|
+
if (typeof token === 'string' && token !== '') state.token = token
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function endpointConfig() {
|
|
50
|
+
return { port: state.port, token: state.token }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Path of the plugin-owned Qoder credential copy. */
|
|
54
|
+
export function ownAuthPath() {
|
|
55
|
+
return join(state.stateDir, '.qoder-credentials.json')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Path of the persisted endpoint settings (port and bearer). */
|
|
59
|
+
export function endpointPath() {
|
|
60
|
+
return join(state.stateDir, 'endpoint.json')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The endpoint's bearer, generated once and persisted.
|
|
65
|
+
*
|
|
66
|
+
* It has to be a *stable* value rather than a per-process secret: the client
|
|
67
|
+
* that points at this endpoint records its bearer in its own configuration
|
|
68
|
+
* file, so a token rotating on every start would break it on restart.
|
|
69
|
+
*/
|
|
70
|
+
export function loadToken(fallback) {
|
|
71
|
+
if (state.token !== '') return state.token
|
|
72
|
+
if (typeof fallback === 'string' && fallback !== '') {
|
|
73
|
+
state.token = fallback
|
|
74
|
+
return state.token
|
|
75
|
+
}
|
|
76
|
+
return ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function setToken(token) {
|
|
80
|
+
if (typeof token === 'string') state.token = token
|
|
81
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// credentials.js — Qoder credential handling for zcode-qoder-connect.
|
|
2
|
+
//
|
|
3
|
+
// Two tracks, tried in order:
|
|
4
|
+
// 1. PAT — a personal access token pasted by the human into
|
|
5
|
+
// %USERPROFILE%\.zcode-qoder-connect\pat.txt (generated at
|
|
6
|
+
// qoder.cn → 账号设置 → 个人访问令牌). Exchanged at
|
|
7
|
+
// POST {openapi}/api/v1/jobToken/exchange {personal_token}.
|
|
8
|
+
// 2. Desktop — Qoder CN's own sign-in vault, reused read-only:
|
|
9
|
+
// %APPDATA%/com.qodercn.app.stable/Local State
|
|
10
|
+
// └─ os_crypt.encrypted_key ("DPAPI" prefix + blob)
|
|
11
|
+
// → DPAPI Unprotect (powershell System.Security) → AES key
|
|
12
|
+
// %APPDATA%/com.qodercn.app.stable/auth.v1.dat
|
|
13
|
+
// └─ "v10" + nonce[12] + AES-256-GCM(ct + tag[16])
|
|
14
|
+
// → { token:"dt-…", refreshToken:"drt-…", expiresAt, user }
|
|
15
|
+
// dt- → POST {openapi}/api/v1/me/jobToken {clientId} → jt-.
|
|
16
|
+
// When dt- is expired the fix is opening the Qoder desktop app
|
|
17
|
+
// once (it refreshes the vault); the drt- refresh endpoint is not
|
|
18
|
+
// public and was not reverse-engineered.
|
|
19
|
+
//
|
|
20
|
+
// Both tracks converge on a job token ("jt-") + userID; COSY-signed gateway
|
|
21
|
+
// calls (models/chat) carry them. Cached for 10 minutes.
|
|
22
|
+
|
|
23
|
+
import { createDecipheriv } from 'node:crypto'
|
|
24
|
+
import { execFileSync } from 'node:child_process'
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
28
|
+
|
|
29
|
+
/** Qoder CN desktop data directory. */
|
|
30
|
+
export function qoderDataDir() {
|
|
31
|
+
const fromEnv = process.env['QODER_DATA_DIR']
|
|
32
|
+
if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv
|
|
33
|
+
return join(process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming'), 'com.qodercn.app.stable')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const OPENAPI_BASE = 'https://openapi.qoder.com.cn'
|
|
37
|
+
const JOB_TOKEN_CLIENT_ID = '732aef47-9cf2-46a2-95fe-4cebb5d0d1fa'
|
|
38
|
+
const CACHE_TTL_MS = 10 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
export const SIGN_IN_HINT =
|
|
41
|
+
'两条路任选:1) 打开 https://qoder.cn/account/integrations 生成个人访问令牌,运行 `node bin/cli.mjs login <PAT>`;2) 打开一次 Qoder CN 桌面程序让它刷新登录态(auth.v1.dat 的 dt- 已过期时必须这样做)'
|
|
42
|
+
|
|
43
|
+
export function patPath(stateDir) {
|
|
44
|
+
return join(stateDir, 'pat.txt')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** DPAPI-unprotect the Chromium master key via PowerShell. */
|
|
48
|
+
function deriveMasterKey(dataDir) {
|
|
49
|
+
const state = JSON.parse(readFileSync(join(dataDir, 'Local State'), 'utf8'))
|
|
50
|
+
const blob = Buffer.from(state.os_crypt.encrypted_key, 'base64')
|
|
51
|
+
if (blob.subarray(0, 5).toString() !== 'DPAPI') throw new Error('Local State encrypted_key 不是 DPAPI 格式')
|
|
52
|
+
const b64 = blob.subarray(5).toString('base64')
|
|
53
|
+
const ps = "Add-Type -AssemblyName System.Security;[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('" + b64 + "'),$null,'CurrentUser'))"
|
|
54
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim()
|
|
55
|
+
const key = Buffer.from(out, 'base64')
|
|
56
|
+
if (key.length !== 32) throw new Error('DPAPI 解出的主密钥长度异常: ' + key.length)
|
|
57
|
+
return key
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** AES-256-GCM decrypt of auth.v1.dat (v10 + nonce[12] + ct + tag[16]). */
|
|
61
|
+
export function decryptAuthVault(masterKey, authPath) {
|
|
62
|
+
const buf = readFileSync(authPath)
|
|
63
|
+
if (!(buf[0] === 0x76 && buf[1] === 0x31 && buf[2] === 0x30)) {
|
|
64
|
+
throw new Error(`auth.v1.dat 魔数异常:${buf.subarray(0, 3).toString('hex')}(期望 v10)`)
|
|
65
|
+
}
|
|
66
|
+
const decipher = createDecipheriv('aes-256-gcm', masterKey, buf.subarray(3, 15))
|
|
67
|
+
decipher.setAuthTag(buf.subarray(buf.length - 16))
|
|
68
|
+
const plaintext = Buffer.concat([decipher.update(buf.subarray(15, buf.length - 16)), decipher.final()])
|
|
69
|
+
return JSON.parse(plaintext.toString('utf8'))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function openApiJson(url, { method, token, body } = {}) {
|
|
73
|
+
const response = await fetch(url, {
|
|
74
|
+
method: method ?? (body !== undefined ? 'POST' : 'GET'),
|
|
75
|
+
headers: {
|
|
76
|
+
accept: 'application/json',
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
79
|
+
},
|
|
80
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
81
|
+
signal: AbortSignal.timeout(20000),
|
|
82
|
+
})
|
|
83
|
+
const text = await response.text()
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const err = new Error(`qoder openapi [${response.status}]: ${text.slice(0, 200)}`)
|
|
86
|
+
err.status = response.status
|
|
87
|
+
throw err
|
|
88
|
+
}
|
|
89
|
+
let payload
|
|
90
|
+
try { payload = JSON.parse(text) } catch { throw new Error('qoder openapi: 非 JSON 响应') }
|
|
91
|
+
if (payload && typeof payload === 'object' && 'code' in payload && payload.code !== 0 && payload.code !== '0') {
|
|
92
|
+
const err = new Error(`qoder openapi code ${payload.code}: ${String(payload.message ?? payload.msg ?? '').slice(0, 160)}`)
|
|
93
|
+
err.status = 401
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** PAT → job token via the documented exchange. */
|
|
100
|
+
export async function exchangePat(pat) {
|
|
101
|
+
return await openApiJson(`${OPENAPI_BASE}/api/v1/jobToken/exchange`, { body: { personal_token: pat } })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** dt- → job token via the desktop client id. */
|
|
105
|
+
export async function exchangeJobToken(dtToken) {
|
|
106
|
+
return await openApiJson(`${OPENAPI_BASE}/api/v1/me/jobToken`, {
|
|
107
|
+
token: dtToken,
|
|
108
|
+
body: { clientId: JOB_TOKEN_CLIENT_ID },
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** jt- → {id, email, name}. */
|
|
113
|
+
async function fetchUserInfo(jobToken) {
|
|
114
|
+
const info = await openApiJson(`${OPENAPI_BASE}/api/v1/userinfo`, { token: jobToken })
|
|
115
|
+
if (!info?.id) throw new Error('qoder userinfo 未返回用户 id')
|
|
116
|
+
return { userID: info.id, email: info.email ?? '', name: info.name ?? info.username ?? 'Qoder User' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readMachineId(dataDir = qoderDataDir()) {
|
|
120
|
+
try { return readFileSync(join(dataDir, 'auth.machine-id'), 'utf8').trim() } catch { return '' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The vault stores expiresAt as either epoch-ms or an ISO string. */
|
|
124
|
+
function dtExpiresAtMs(auth) {
|
|
125
|
+
const v = auth?.expiresAt
|
|
126
|
+
if (typeof v === 'number') return v
|
|
127
|
+
if (typeof v === 'string') { const p = Date.parse(v); return Number.isNaN(p) ? 0 : p }
|
|
128
|
+
return 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class QoderCredentialStore {
|
|
132
|
+
constructor({ stateDir, dataDir } = {}) {
|
|
133
|
+
this.stateDir = stateDir
|
|
134
|
+
this.dataDir = dataDir ?? qoderDataDir()
|
|
135
|
+
this.cache = null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Which tracks are configured, without contacting the network. */
|
|
139
|
+
status() {
|
|
140
|
+
const pat = existsSync(patPath(this.stateDir))
|
|
141
|
+
const vault = existsSync(join(this.dataDir, 'auth.v1.dat'))
|
|
142
|
+
let dtFresh = false
|
|
143
|
+
if (vault) {
|
|
144
|
+
try {
|
|
145
|
+
const masterKey = deriveMasterKey(this.dataDir)
|
|
146
|
+
const auth = decryptAuthVault(masterKey, join(this.dataDir, 'auth.v1.dat'))
|
|
147
|
+
dtFresh = dtExpiresAtMs(auth) > Date.now()
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
return { patConfigured: pat, desktopVault: vault, desktopTokenFresh: dtFresh }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async resolve() {
|
|
154
|
+
if (this.cache && this.cache.expiresAt > Date.now()) return this.cache.credential
|
|
155
|
+
let jobToken = null
|
|
156
|
+
let source = ''
|
|
157
|
+
const patFile = patPath(this.stateDir)
|
|
158
|
+
if (existsSync(patFile)) {
|
|
159
|
+
const pat = readFileSync(patFile, 'utf8').trim()
|
|
160
|
+
if (pat) {
|
|
161
|
+
const data = await exchangePat(pat)
|
|
162
|
+
if (data?.token) { jobToken = data.token; source = 'pat' }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!jobToken && existsSync(join(this.dataDir, 'auth.v1.dat'))) {
|
|
166
|
+
const masterKey = deriveMasterKey(this.dataDir)
|
|
167
|
+
const auth = decryptAuthVault(masterKey, join(this.dataDir, 'auth.v1.dat'))
|
|
168
|
+
if (!(dtExpiresAtMs(auth) > Date.now())) throw new Error(`Qoder 桌面 dt- 令牌已过期(${new Date(dtExpiresAtMs(auth)).toISOString()})。${SIGN_IN_HINT}`)
|
|
169
|
+
const data = await exchangeJobToken(auth.token)
|
|
170
|
+
if (data?.token) { jobToken = data.token; source = 'desktop' }
|
|
171
|
+
}
|
|
172
|
+
if (!jobToken) throw new Error(`没有任何可用的 Qoder 凭据。${SIGN_IN_HINT}`)
|
|
173
|
+
const info = await fetchUserInfo(jobToken)
|
|
174
|
+
const credential = {
|
|
175
|
+
userID: info.userID,
|
|
176
|
+
authToken: jobToken,
|
|
177
|
+
name: info.name,
|
|
178
|
+
email: info.email,
|
|
179
|
+
machineID: readMachineId(this.dataDir) || '00000000-0000-0000-0000-000000000000',
|
|
180
|
+
source,
|
|
181
|
+
}
|
|
182
|
+
this.cache = { credential, expiresAt: Date.now() + CACHE_TTL_MS }
|
|
183
|
+
return credential
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/loopback.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback checks for the local OpenAI-compatible endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Binding to 127.0.0.1 is not by itself a trust boundary: any local process,
|
|
5
|
+
* and a DNS-rebinding page in a browser, can reach a loopback port. Every
|
|
6
|
+
* request must therefore also name a loopback Host, and browser-sent Origins
|
|
7
|
+
* must be loopback too, so a rebinding page is rejected on both counts.
|
|
8
|
+
*
|
|
9
|
+
* @module zcode-trae-connect/loopback
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Hostname of a Host header value, with the port and IPv6 brackets removed. */
|
|
13
|
+
export function hostnameOfHost(host) {
|
|
14
|
+
if (typeof host !== 'string' || host === '') return ''
|
|
15
|
+
const trimmed = host.trim()
|
|
16
|
+
// IPv6 literal: [::1]:1234 or [::1]
|
|
17
|
+
const v6 = /^\[([^\]]+)\](?::\d+)?$/u.exec(trimmed)
|
|
18
|
+
if (v6 !== null) return (v6[1] ?? '').toLowerCase()
|
|
19
|
+
const colon = trimmed.lastIndexOf(':')
|
|
20
|
+
return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a hostname names the loopback interface. */
|
|
24
|
+
export function hostIsLoopback(host) {
|
|
25
|
+
const name = hostnameOfHost(host)
|
|
26
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === '[::1]'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether an Origin header (absent counts as fine) is a loopback origin. */
|
|
30
|
+
export function originIsLoopback(origin) {
|
|
31
|
+
if (origin === undefined || origin === null || origin === '') return true
|
|
32
|
+
if (typeof origin !== 'string') return false
|
|
33
|
+
let parsed
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(origin)
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
return hostIsLoopback(parsed.host)
|
|
40
|
+
}
|