@loommii/dsh-provider-usage 0.6.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 +63 -0
- package/cordis.patch.yml +7 -0
- package/docs/CHANGELOG.md +120 -0
- package/lib/client.js +1164 -0
- package/lib/daily-stats.js +324 -0
- package/lib/index.js +1104 -0
- package/lib/secure-store.js +136 -0
- package/package.json +66 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// dsh-provider-usage — 私有加密凭证库(方案 B,2026-08-24 用户决策)。
|
|
2
|
+
// 用途:用户"手动填写"的 AI Key 加密落盘,不写入 DSH 全局凭证表。
|
|
3
|
+
// 布局:
|
|
4
|
+
// $DSH_HOME/provider-usage/secret.key —— AES-256 密钥(随机生成一次,0600)
|
|
5
|
+
// $DSH_HOME/provider-usage/credentials.json —— {version, credentials:{ref: ciphertext}}(0600)
|
|
6
|
+
// 加密:AES-256-GCM,密文格式 v1:<b64(iv)>:<b64(authTag)>:<b64(data)>
|
|
7
|
+
// 边界(与 DSH 凭证同款):0600 挡其他 OS 用户;同一用户的其它进程(含 agent)
|
|
8
|
+
// 理论上仍可读——更强隔离需 OS 钥匙串(DSH 官方路线图延后项)。
|
|
9
|
+
|
|
10
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
|
|
11
|
+
import { mkdir, readFile, writeFile, chmod, unlink } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import process from 'node:process'
|
|
15
|
+
|
|
16
|
+
const KEY_FILENAME = 'secret.key'
|
|
17
|
+
const CRED_FILENAME = 'credentials.json'
|
|
18
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
19
|
+
|
|
20
|
+
/** 计算 $DSH_HOME/provider-usage 目录:优先 DSH_HOME,回落 ~/.dsh。 */
|
|
21
|
+
export function resolveProviderUsageDir() {
|
|
22
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
23
|
+
return join(home, 'provider-usage')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 解析一个 ref 是否为合法凭证引用名。 */
|
|
27
|
+
export function isValidRefName(name) {
|
|
28
|
+
return typeof name === 'string' && REF_PATTERN.test(name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encrypt(key, value) {
|
|
32
|
+
const iv = randomBytes(12)
|
|
33
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
34
|
+
const data = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()])
|
|
35
|
+
const tag = cipher.getAuthTag()
|
|
36
|
+
return 'v1:' + iv.toString('base64') + ':' + tag.toString('base64') + ':' + data.toString('base64')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decrypt(key, token) {
|
|
40
|
+
const parts = String(token).split(':')
|
|
41
|
+
if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('bad ciphertext format')
|
|
42
|
+
const iv = Buffer.from(parts[1], 'base64')
|
|
43
|
+
const tag = Buffer.from(parts[2], 'base64')
|
|
44
|
+
const data = Buffer.from(parts[3], 'base64')
|
|
45
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
46
|
+
decipher.setAuthTag(tag)
|
|
47
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJson(file) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(file, 'utf8')
|
|
53
|
+
const doc = JSON.parse(raw)
|
|
54
|
+
if (doc && doc.version === 1 && doc.credentials && typeof doc.credentials === 'object') return doc
|
|
55
|
+
} catch (e) { /* missing/corrupt → 空库 */ }
|
|
56
|
+
return { version: 1, credentials: {} }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function writeJson(file, doc) {
|
|
60
|
+
const tmp = file + '.tmp'
|
|
61
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 })
|
|
62
|
+
await chmod(tmp, 0o600)
|
|
63
|
+
// 尽力原子换名;失败时直接写目标
|
|
64
|
+
try { await (await import('node:fs/promises')).rename(tmp, file) } catch (e) { await writeFile(file, JSON.stringify(doc, null, 2), { mode: 0o600 }) }
|
|
65
|
+
await chmod(file, 0o600)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 私有加密凭证库实例。
|
|
70
|
+
* init():建目录(0700)、生成密钥(0600)、种子空库(0600)。密钥永久复用,丢失即无法解密。
|
|
71
|
+
*/
|
|
72
|
+
export function createSecureStore(dir) {
|
|
73
|
+
let key = null
|
|
74
|
+
const keyFile = join(dir, KEY_FILENAME)
|
|
75
|
+
const credFile = join(dir, CRED_FILENAME)
|
|
76
|
+
// 文档内存缓存:has/get/list 不再每次读盘+解析;所有写路径同步更新缓存。
|
|
77
|
+
let docCache = null
|
|
78
|
+
let docLoaded = false
|
|
79
|
+
|
|
80
|
+
async function doc() {
|
|
81
|
+
if (!docLoaded) { docCache = await readJson(credFile); docLoaded = true }
|
|
82
|
+
return docCache
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function writeDoc(next) {
|
|
86
|
+
docCache = next
|
|
87
|
+
await writeJson(credFile, next)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function init() {
|
|
91
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
92
|
+
try {
|
|
93
|
+
const raw = await readFile(keyFile, 'utf8')
|
|
94
|
+
key = Buffer.from(raw.trim(), 'base64')
|
|
95
|
+
if (key.length !== 32) throw new Error('bad key length')
|
|
96
|
+
} catch (e) {
|
|
97
|
+
key = randomBytes(32)
|
|
98
|
+
await writeFile(keyFile, key.toString('base64') + '\n', { mode: 0o600 })
|
|
99
|
+
await chmod(keyFile, 0o600)
|
|
100
|
+
}
|
|
101
|
+
await writeDoc(await readJson(credFile))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function set(ref, value) {
|
|
105
|
+
const d = await doc()
|
|
106
|
+
d.credentials[ref] = encrypt(key, value)
|
|
107
|
+
await writeDoc(d)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function get(ref) {
|
|
111
|
+
const d = await doc()
|
|
112
|
+
const token = d.credentials[ref]
|
|
113
|
+
if (!token) return null
|
|
114
|
+
try { return decrypt(key, token) } catch (e) { return null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function has(ref) {
|
|
118
|
+
const d = await doc()
|
|
119
|
+
return Object.prototype.hasOwnProperty.call(d.credentials, ref)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function list() {
|
|
123
|
+
const d = await doc()
|
|
124
|
+
return Object.keys(d.credentials)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function remove(ref) {
|
|
128
|
+
const d = await doc()
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(d.credentials, ref)) {
|
|
130
|
+
delete d.credentials[ref]
|
|
131
|
+
await writeDoc(d)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { dir, keyFile, credFile, init, set, get, has, list, remove, isValidRefName }
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loommii/dsh-provider-usage",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "DSH 插件(用量中心):常驻用量卡片 · 多提供方用量/余额查询(OpenCode Go 订阅 + DeepSeek 余额 + Command Code 订阅/余额,对齐 cc-switch 查询语义)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node tests/host.test.mjs && node tests/daily-stats.test.mjs && node tests/secure-store.test.mjs && node tests/smoke.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"lib",
|
|
12
|
+
"cordis.patch.yml",
|
|
13
|
+
"README.md",
|
|
14
|
+
"docs/CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"provider-usage",
|
|
21
|
+
"usage",
|
|
22
|
+
"balance",
|
|
23
|
+
"opencode-go",
|
|
24
|
+
"deepseek",
|
|
25
|
+
"command-code"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/loommii/dsh-provider-usage.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/loommii/dsh-provider-usage/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/loommii/dsh-provider-usage#readme",
|
|
35
|
+
"author": "loommii",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"exports": {
|
|
41
|
+
".": "./lib/index.js",
|
|
42
|
+
"./client": "./lib/client.js",
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
58
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
59
|
+
"react": "^18.2.0",
|
|
60
|
+
"react-dom": "^18.2.0"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@bokuweb/zstd-wasm": "^0.0.27",
|
|
64
|
+
"fzstd": "^0.1.1"
|
|
65
|
+
}
|
|
66
|
+
}
|