@jiangzhenguo/dsh-safebox 0.0.0-stage → 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jiangzhenguo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # DSH Safebox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Safebox 是一个 DeepSeek Harness(DSH)本地密钥管理插件。它在 **设置 → Safebox 密钥管理** 中提供独立面板,用于保存账号密码、API Token 和加密文本,并向 Agent 暴露不含秘密明文的引用与使用目标。
4
+
5
+ ## 当前能力
6
+
7
+ - 在 DSH 设置中新增、编辑和删除密钥条目;
8
+ - 秘密值通过 DSH 凭据服务单向写入,页面不提供回显或导出;
9
+ - 支持经用户确认的本地 JSON 导入;
10
+ - `safebox_list` 只返回白名单元数据:名称、类型、账号、使用目标、引用和时间戳;
11
+ - Agent 没有 `get_secret`、秘密导出或授权修改工具。
12
+
13
+ ## 安全边界
14
+
15
+ Safebox 防止秘密通过插件配置、页面、Agent 工具结果进入聊天和模型上下文,但当前版本使用 DSH 默认本地凭据存储。**同一 OS 用户运行的进程不是绝对隔离边界**,本版本不宣称可以抵御读取本机凭据文件的恶意进程。
16
+
17
+ 本地 JSON 导入文件可能包含明文。导入后请按源产品安全指引入库并妥善处理源文件,不要把真实凭据提交到 Git。
18
+
19
+ ## 环境与安装
20
+
21
+ - DSH(当前验证版本:`0.2.2-rc.1`)
22
+ - Node.js 20+
23
+ - pnpm 11+
24
+
25
+ 安装依赖:
26
+
27
+ ```bash
28
+ pnpm install --ignore-scripts
29
+ ```
30
+
31
+ 在 DSH 插件管理中选择安装本地 Bundle,并选择本项目根目录。安装成功后,打开 **设置 → Safebox 密钥管理**。
32
+
33
+ ## 开发验证
34
+
35
+ ```bash
36
+ pnpm run verify
37
+ ```
38
+
39
+ 该命令执行:
40
+
41
+ - Host 与 Client JavaScript 语法检查;
42
+ - Bundle、导出资源、图标和包名一致性检查;
43
+ - Node.js 内置测试,包括 `safebox_list` 不泄露额外秘密字段的安全回归测试。
44
+
45
+ ## 项目结构
46
+
47
+ ```text
48
+ .
49
+ ├── .github/workflows/ci.yml # GitHub Actions 验证
50
+ ├── locale/ # 插件管理器显示文本
51
+ ├── scripts/verify.mjs # 清单与静态检查
52
+ ├── test/host.test.mjs # Host 与安全回归测试
53
+ ├── client.js # 设置面板
54
+ ├── cordis.patch.yml # DSH Bundle 组合配置
55
+ ├── icon.svg # 插件图标
56
+ ├── index.js # Host 配置和 Agent 工具
57
+ ├── package.json # 包、脚本和 DSH Client 声明
58
+ └── pnpm-lock.yaml # 可复现依赖锁
59
+ ```
60
+
61
+ ## 本地 JSON 导入格式
62
+
63
+ 支持 JSON 数组或带 `items` 数组的对象,每项包含:
64
+
65
+ ```json
66
+ {
67
+ "name": "Example API",
68
+ "kind": "api-token",
69
+ "secret": "replace-with-a-test-value",
70
+ "username": "",
71
+ "target": "https://api.example.com"
72
+ }
73
+ ```
74
+
75
+ `kind` 支持 `account`、`api-token`、`secret-text`。单次最多 100 条、文件最大 1 MB。请只使用测试数据验证,不要提交真实导入文件。
76
+
77
+ ## npm 包
78
+
79
+ 公开包名为 `@jiangzhenguo/dsh-safebox`。支持 CLI 的 DSH Profile 可以安装:
80
+
81
+ ```bash
82
+ dsh plugin --profile web add @jiangzhenguo/dsh-safebox
83
+ ```
84
+
85
+ DSH Desktop 请使用应用内的插件管理器安装。发布前运行 `pnpm run verify`,并确认没有真实凭据、`.env`、凭据 YAML 或 `node_modules` 被提交。
86
+
87
+ ## 许可证
88
+
89
+ 本项目使用 [MIT License](LICENSE)。
package/client.js ADDED
@@ -0,0 +1,406 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@jiangzhenguo/dsh-safebox',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const NS = 'safebox'
7
+ const ENTRY_ID = 'safebox'
8
+ const KINDS = ['account', 'api-token', 'secret-text']
9
+
10
+ const zh = {
11
+ nav: 'Safebox 密钥',
12
+ title: 'Safebox 密钥管理',
13
+ intro: '在本机管理账号密码、API Token 和加密文本。秘密值只写入本机凭据存储,不会显示在页面、聊天或模型上下文中。',
14
+ agentNote: 'Agent 只能读取密钥引用和已确认的使用目标,不能读取秘密值。新增或修改使用目标需由你在这里保存确认。',
15
+ add: '新增密钥',
16
+ import: '本地导入',
17
+ importHint: '导入 JSON 数组或 { "items": [...] };每项包含 name、kind、secret,可选 username、target。选择文件后还需确认。',
18
+ empty: '还没有密钥。新增一条,或从本地 JSON 文件导入。',
19
+ loading: '正在读取本机密钥列表…',
20
+ name: '名称',
21
+ namePlaceholder: '例如:生产环境 API',
22
+ kind: '类型',
23
+ 'kind.account': '账号密码',
24
+ 'kind.api-token': 'API Token',
25
+ 'kind.secret-text': '加密文本',
26
+ username: '账号',
27
+ usernamePlaceholder: '用户名或邮箱(可选)',
28
+ target: '使用目标',
29
+ targetPlaceholder: '例如:https://api.example.com(可选)',
30
+ secret: '秘密值',
31
+ secretCreatePlaceholder: '输入后只保存,不再回显',
32
+ secretEditPlaceholder: '留空表示保留当前秘密值',
33
+ save: '确认保存',
34
+ saving: '保存中…',
35
+ cancel: '取消',
36
+ edit: '编辑',
37
+ remove: '删除',
38
+ reference: '密钥引用',
39
+ noTarget: '未设置使用目标',
40
+ deleteTitle: '删除这条密钥?',
41
+ deleteDescription: '将删除本机保存的秘密值和引用元数据,此操作不能撤销。',
42
+ confirmDelete: '确认删除',
43
+ importTitle: '确认导入本地文件',
44
+ importCount: '将导入 {count} 条密钥。文件中的秘密值只会写入本机凭据存储。',
45
+ confirmImport: '确认导入',
46
+ importInvalid: '无法导入:请选择符合格式要求的 JSON 文件,每条名称和秘密值都不能为空。',
47
+ saved: '已保存。',
48
+ deleted: '已删除。',
49
+ imported: '导入完成。',
50
+ error: '操作失败,请重试。若仍失败,请检查本机凭据存储是否可写。',
51
+ closeEditor: '收起编辑',
52
+ }
53
+
54
+ const en = {
55
+ nav: 'Safebox secrets',
56
+ title: 'Safebox secret manager',
57
+ intro: 'Manage account passwords, API tokens, and secret text on this machine. Values are written only to the local credential store and never appear in this page, chat, or model context.',
58
+ agentNote: 'The agent can read only secret references and confirmed usage targets, never secret values. Adding or changing a usage target requires your explicit save here.',
59
+ add: 'Add secret',
60
+ import: 'Import locally',
61
+ importHint: 'Import a JSON array or { "items": [...] }. Each item needs name, kind, and secret; username and target are optional. You confirm after choosing a file.',
62
+ empty: 'No secrets yet. Add one or import a local JSON file.',
63
+ loading: 'Reading the local secret list…',
64
+ name: 'Name',
65
+ namePlaceholder: 'For example, Production API',
66
+ kind: 'Type',
67
+ 'kind.account': 'Account password',
68
+ 'kind.api-token': 'API token',
69
+ 'kind.secret-text': 'Secret text',
70
+ username: 'Account',
71
+ usernamePlaceholder: 'Username or email (optional)',
72
+ target: 'Usage target',
73
+ targetPlaceholder: 'For example, https://api.example.com (optional)',
74
+ secret: 'Secret value',
75
+ secretCreatePlaceholder: 'Write-only: saved values are never shown',
76
+ secretEditPlaceholder: 'Leave blank to keep the current secret',
77
+ save: 'Confirm and save',
78
+ saving: 'Saving…',
79
+ cancel: 'Cancel',
80
+ edit: 'Edit',
81
+ remove: 'Delete',
82
+ reference: 'Secret reference',
83
+ noTarget: 'No usage target set',
84
+ deleteTitle: 'Delete this secret?',
85
+ deleteDescription: 'This removes the local secret value and its reference metadata. This cannot be undone.',
86
+ confirmDelete: 'Confirm deletion',
87
+ importTitle: 'Confirm local import',
88
+ importCount: 'Import {count} secrets. Their values are written only to the local credential store.',
89
+ confirmImport: 'Confirm import',
90
+ importInvalid: 'Cannot import: choose a valid JSON file with a non-empty name and secret for every item.',
91
+ saved: 'Saved.',
92
+ deleted: 'Deleted.',
93
+ imported: 'Import complete.',
94
+ error: 'The operation failed. Try again, and check that the local credential store is writable.',
95
+ closeEditor: 'Close editor',
96
+ }
97
+
98
+ const css = `
99
+ .dsh-safebox-section{box-sizing:border-box;width:100%;max-width:760px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:14px;font-size:14px;line-height:22px}
100
+ .dsh-safebox-section *{box-sizing:border-box}
101
+ .dsh-safebox-title{margin:0;font-size:18px;font-weight:600;line-height:26px}
102
+ .dsh-safebox-intro,.dsh-safebox-note{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
103
+ .dsh-safebox-note{border-left:2px solid var(--dsw-alias-border-l3);padding-left:10px}
104
+ .dsh-safebox-toolbar,.dsh-safebox-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
105
+ .dsh-safebox-button{border:0;border-radius:var(--dsw-radius-md);min-height:34px;padding:6px 12px;font:inherit;font-size:13px;cursor:pointer;background:var(--dsw-alias-interactive-bg);color:var(--dsw-alias-label-primary)}
106
+ .dsh-safebox-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
107
+ .dsh-safebox-button:focus-visible,.dsh-safebox-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
108
+ .dsh-safebox-button:disabled{opacity:.55;cursor:default}
109
+ .dsh-safebox-primary{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-on-color,var(--dsw-alias-label-primary))}
110
+ .dsh-safebox-primary:hover:not(:disabled){background:var(--dsw-alias-state-business-primary)}
111
+ .dsh-safebox-danger{color:var(--dsw-alias-state-error-primary)}
112
+ .dsh-safebox-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
113
+ .dsh-safebox-card,.dsh-safebox-editor,.dsh-safebox-confirm{border:.5px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l2));border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1));padding:14px;display:flex;flex-direction:column;gap:12px}
114
+ .dsh-safebox-cardHead{display:flex;align-items:center;gap:8px;min-width:0}
115
+ .dsh-safebox-cardName{font-weight:600;overflow-wrap:anywhere}
116
+ .dsh-safebox-tag{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs);color:var(--dsw-alias-label-secondary);padding:0 6px;font-size:11px;line-height:18px;white-space:nowrap}
117
+ .dsh-safebox-cardHead .dsh-safebox-actions{margin-left:auto}
118
+ .dsh-safebox-meta{margin:0;display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 12px;font-size:12px;line-height:20px}
119
+ .dsh-safebox-meta dt{color:var(--dsw-alias-label-secondary)}
120
+ .dsh-safebox-meta dd{margin:0;overflow-wrap:anywhere}
121
+ .dsh-safebox-ref{font-family:var(--ds-font-family-code,monospace)}
122
+ .dsh-safebox-field{display:flex;flex-direction:column;gap:5px}
123
+ .dsh-safebox-label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
124
+ .dsh-safebox-input{width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-input,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);padding:8px 10px;font:inherit;font-size:13px;line-height:20px}
125
+ textarea.dsh-safebox-input{min-height:88px;resize:vertical;font-family:var(--ds-font-family-code,monospace)}
126
+ .dsh-safebox-confirmTitle{margin:0;font-size:14px;font-weight:600}
127
+ .dsh-safebox-confirmText{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px}
128
+ .dsh-safebox-importNames{margin:0;padding-left:20px;max-height:140px;overflow:auto;color:var(--dsw-alias-label-secondary);font-size:12px}
129
+ .dsh-safebox-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px}
130
+ .dsh-safebox-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:13px}
131
+ .dsh-safebox-success{margin:0;color:var(--dsw-alias-state-success-primary);font-size:13px}
132
+ .dsh-safebox-file{display:none}
133
+ `
134
+
135
+ function LockIcon({ size = 16 }) {
136
+ return h('svg', {
137
+ width: size, height: size, viewBox: '0 0 20 20', fill: 'none',
138
+ stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round',
139
+ strokeLinejoin: 'round', 'aria-hidden': true,
140
+ },
141
+ h('rect', { x: 4, y: 8.5, width: 12, height: 9, rx: 2 }),
142
+ h('path', { d: 'M6.5 8.5V6a3.5 3.5 0 0 1 7 0v2.5M10 12v2.2' }))
143
+ }
144
+
145
+ function Field({ label, children }) {
146
+ return h('label', { className: 'dsh-safebox-field' },
147
+ h('span', { className: 'dsh-safebox-label' }, label), children)
148
+ }
149
+
150
+ function Editor({ initial, busy, onCancel, onSave, t }) {
151
+ const editing = initial !== undefined
152
+ const [name, setName] = React.useState(initial?.name ?? '')
153
+ const [kind, setKind] = React.useState(initial?.kind ?? 'account')
154
+ const [username, setUsername] = React.useState(initial?.username ?? '')
155
+ const [target, setTarget] = React.useState(initial?.target ?? '')
156
+ const [secret, setSecret] = React.useState('')
157
+ const valid = name.trim().length > 0 && (!editing || secret.length === 0 || secret.length <= 65536) && (editing || secret.length > 0)
158
+ const inputProps = { className: 'dsh-safebox-input', disabled: busy }
159
+ return h('form', {
160
+ className: 'dsh-safebox-editor',
161
+ onSubmit: (event) => {
162
+ event.preventDefault()
163
+ if (valid && !busy) onSave({ name: name.trim(), kind, username: username.trim(), target: target.trim(), secret })
164
+ },
165
+ },
166
+ h(Field, { label: t('name') }, h('input', {
167
+ ...inputProps, value: name, maxLength: 200, required: true,
168
+ placeholder: t('namePlaceholder'), onChange: (event) => setName(event.target.value),
169
+ })),
170
+ h(Field, { label: t('kind') }, h('select', {
171
+ ...inputProps, value: kind, onChange: (event) => setKind(event.target.value),
172
+ }, KINDS.map((value) => h('option', { key: value, value }, t(`kind.${value}`))))),
173
+ kind === 'account' && h(Field, { label: t('username') }, h('input', {
174
+ ...inputProps, value: username, maxLength: 500,
175
+ autoComplete: 'username', placeholder: t('usernamePlaceholder'),
176
+ onChange: (event) => setUsername(event.target.value),
177
+ })),
178
+ h(Field, { label: t('target') }, h('input', {
179
+ ...inputProps, value: target, maxLength: 2000,
180
+ placeholder: t('targetPlaceholder'), onChange: (event) => setTarget(event.target.value),
181
+ })),
182
+ h(Field, { label: t('secret') },
183
+ kind === 'secret-text'
184
+ ? h('textarea', {
185
+ ...inputProps, value: secret, maxLength: 65536,
186
+ required: !editing, autoComplete: 'new-password',
187
+ placeholder: t(editing ? 'secretEditPlaceholder' : 'secretCreatePlaceholder'),
188
+ onChange: (event) => setSecret(event.target.value),
189
+ })
190
+ : h('input', {
191
+ ...inputProps, value: secret, maxLength: 65536, required: !editing,
192
+ type: 'password', autoComplete: 'new-password',
193
+ placeholder: t(editing ? 'secretEditPlaceholder' : 'secretCreatePlaceholder'),
194
+ onChange: (event) => setSecret(event.target.value),
195
+ })),
196
+ h('div', { className: 'dsh-safebox-actions' },
197
+ h('button', { type: 'button', className: 'dsh-safebox-button', disabled: busy, onClick: onCancel }, t('cancel')),
198
+ h('button', { type: 'submit', className: 'dsh-safebox-button dsh-safebox-primary', disabled: busy || !valid }, busy ? t('saving') : t('save'))))
199
+ }
200
+
201
+ function newIdentity() {
202
+ const id = `item-${globalThis.crypto.randomUUID()}`
203
+ return { id, ref: `SAFEBOX_${id.replaceAll('-', '_').toUpperCase()}` }
204
+ }
205
+
206
+ function currentItems(form) {
207
+ return form.getSnapshot().value?.items ?? []
208
+ }
209
+
210
+ async function checked(result) {
211
+ const answer = await result
212
+ if (!answer.ok) throw answer.error
213
+ return answer.value
214
+ }
215
+
216
+ function readImport(text) {
217
+ if (text.length > 1000000) throw new Error('import too large')
218
+ const root = JSON.parse(text)
219
+ const rows = Array.isArray(root) ? root : root?.items
220
+ if (!Array.isArray(rows) || rows.length === 0 || rows.length > 100) throw new Error('invalid items')
221
+ return rows.map((row) => {
222
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) throw new Error('invalid item')
223
+ const name = typeof row.name === 'string' ? row.name.trim() : ''
224
+ const kind = KINDS.includes(row.kind) ? row.kind : ''
225
+ const secret = typeof row.secret === 'string' ? row.secret : ''
226
+ const username = row.username === undefined ? '' : typeof row.username === 'string' ? row.username.trim() : ''
227
+ const target = row.target === undefined ? '' : typeof row.target === 'string' ? row.target.trim() : ''
228
+ if (!name || name.length > 200 || !kind || !secret || secret.length > 65536 || username.length > 500 || target.length > 2000) throw new Error('invalid item')
229
+ return { name, kind, secret, username, target }
230
+ })
231
+ }
232
+
233
+ function SafeboxSection({ form, credentials, t }) {
234
+ const subscribe = React.useCallback((listener) => form.subscribe(listener), [form])
235
+ const getSnapshot = React.useCallback(() => form.getSnapshot(), [form])
236
+ const snapshot = React.useSyncExternalStore(subscribe, getSnapshot)
237
+ const items = snapshot.value?.items ?? []
238
+ const [editor, setEditor] = React.useState(null)
239
+ const [deleteId, setDeleteId] = React.useState(null)
240
+ const [pendingImport, setPendingImport] = React.useState(null)
241
+ const [busy, setBusy] = React.useState(false)
242
+ const [error, setError] = React.useState('')
243
+ const [notice, setNotice] = React.useState('')
244
+ const fileInput = React.useRef(null)
245
+
246
+ const run = async (operation, success) => {
247
+ setBusy(true)
248
+ setError('')
249
+ setNotice('')
250
+ try {
251
+ await operation()
252
+ setNotice(success)
253
+ } catch {
254
+ setError('error')
255
+ } finally {
256
+ setBusy(false)
257
+ }
258
+ }
259
+
260
+ const create = (draft) => run(async () => {
261
+ const identity = newIdentity()
262
+ const now = Date.now()
263
+ const item = { ...identity, ...draft, secret: undefined, createdAt: now, updatedAt: now }
264
+ delete item.secret
265
+ await checked(credentials.set(identity.ref, draft.secret))
266
+ try {
267
+ if (!await form.set('items', [...currentItems(form), item])) throw new Error('settings write refused')
268
+ } catch (error) {
269
+ await checked(credentials.unset(identity.ref)).catch(() => {})
270
+ throw error
271
+ }
272
+ setEditor(null)
273
+ }, 'saved')
274
+
275
+ const update = (initial, draft) => run(async () => {
276
+ if (draft.secret) await checked(credentials.set(initial.ref, draft.secret))
277
+ const next = currentItems(form).map((item) => item.id === initial.id
278
+ ? { ...item, name: draft.name, kind: draft.kind, username: draft.username, target: draft.target, updatedAt: Date.now() }
279
+ : item)
280
+ if (!next.some((item) => item.id === initial.id)) throw new Error('item missing')
281
+ if (!await form.set('items', next)) throw new Error('settings write refused')
282
+ setEditor(null)
283
+ }, 'saved')
284
+
285
+ const remove = (item) => run(async () => {
286
+ const previous = currentItems(form)
287
+ if (!await form.set('items', previous.filter((candidate) => candidate.id !== item.id))) throw new Error('settings write refused')
288
+ try {
289
+ await checked(credentials.unset(item.ref))
290
+ } catch (error) {
291
+ await form.set('items', previous)
292
+ throw error
293
+ }
294
+ setDeleteId(null)
295
+ }, 'deleted')
296
+
297
+ const chooseImport = async (event) => {
298
+ const file = event.target.files?.[0]
299
+ event.target.value = ''
300
+ if (!file) return
301
+ setError('')
302
+ setNotice('')
303
+ try {
304
+ if (file.size > 1000000) throw new Error('import too large')
305
+ setPendingImport(await readImport(await file.text()))
306
+ } catch {
307
+ setPendingImport(null)
308
+ setError('importInvalid')
309
+ }
310
+ }
311
+
312
+ const importConfirmed = () => run(async () => {
313
+ const drafts = pendingImport ?? []
314
+ if (drafts.length === 0) throw new Error('nothing to import')
315
+ const now = Date.now()
316
+ const additions = drafts.map((draft) => {
317
+ const item = { ...newIdentity(), ...draft, secret: undefined, createdAt: now, updatedAt: now }
318
+ delete item.secret
319
+ return item
320
+ })
321
+ const stored = []
322
+ try {
323
+ for (let index = 0; index < drafts.length; index++) {
324
+ await checked(credentials.set(additions[index].ref, drafts[index].secret))
325
+ stored.push(additions[index].ref)
326
+ }
327
+ if (!await form.set('items', [...currentItems(form), ...additions])) throw new Error('settings write refused')
328
+ } catch (error) {
329
+ await Promise.all(stored.map((ref) => checked(credentials.unset(ref)).catch(() => {})))
330
+ throw error
331
+ }
332
+ setPendingImport(null)
333
+ }, 'imported')
334
+
335
+ return h('section', { className: 'dsh-safebox-section' },
336
+ h('style', null, css),
337
+ h('h2', { className: 'dsh-safebox-title' }, t('title')),
338
+ h('p', { className: 'dsh-safebox-intro' }, t('intro')),
339
+ h('p', { className: 'dsh-safebox-note' }, t('agentNote')),
340
+ h('div', { className: 'dsh-safebox-toolbar' },
341
+ h('button', {
342
+ type: 'button', className: 'dsh-safebox-button dsh-safebox-primary', disabled: busy,
343
+ onClick: () => { setEditor(editor?.mode === 'create' ? null : { mode: 'create' }); setDeleteId(null); setError(''); setNotice('') },
344
+ }, editor?.mode === 'create' ? t('closeEditor') : t('add')),
345
+ h('button', {
346
+ type: 'button', className: 'dsh-safebox-button', disabled: busy,
347
+ onClick: () => { fileInput.current?.click(); setDeleteId(null); setError(''); setNotice('') },
348
+ }, t('import')),
349
+ h('input', { ref: fileInput, className: 'dsh-safebox-file', type: 'file', accept: '.json,application/json', onChange: chooseImport, tabIndex: -1 })),
350
+ h('p', { className: 'dsh-safebox-status' }, t('importHint')),
351
+ error && h('p', { className: 'dsh-safebox-error', role: 'alert' }, t(error)),
352
+ notice && h('p', { className: 'dsh-safebox-success', role: 'status' }, t(notice)),
353
+ editor && h(Editor, {
354
+ key: editor.mode === 'edit' ? editor.item.id : 'create',
355
+ initial: editor.item, busy, t,
356
+ onCancel: () => setEditor(null),
357
+ onSave: (draft) => editor.mode === 'edit' ? update(editor.item, draft) : create(draft),
358
+ }),
359
+ pendingImport && h('div', { className: 'dsh-safebox-confirm', role: 'group', 'aria-label': t('importTitle') },
360
+ h('h3', { className: 'dsh-safebox-confirmTitle' }, t('importTitle')),
361
+ h('p', { className: 'dsh-safebox-confirmText' }, t('importCount', { count: pendingImport.length })),
362
+ h('ul', { className: 'dsh-safebox-importNames' }, pendingImport.map((item, index) => h('li', { key: `${index}-${item.name}` }, item.name))),
363
+ h('div', { className: 'dsh-safebox-actions' },
364
+ h('button', { type: 'button', className: 'dsh-safebox-button', disabled: busy, onClick: () => setPendingImport(null) }, t('cancel')),
365
+ h('button', { type: 'button', className: 'dsh-safebox-button dsh-safebox-primary', disabled: busy, onClick: importConfirmed }, busy ? t('saving') : t('confirmImport')))),
366
+ snapshot.status === 'loading'
367
+ ? h('p', { className: 'dsh-safebox-status' }, t('loading'))
368
+ : items.length === 0
369
+ ? h('p', { className: 'dsh-safebox-status' }, t('empty'))
370
+ : h('ul', { className: 'dsh-safebox-list' }, items.map((item) => h('li', { key: item.id },
371
+ h('article', { className: 'dsh-safebox-card' },
372
+ h('div', { className: 'dsh-safebox-cardHead' },
373
+ h('span', { className: 'dsh-safebox-cardName' }, item.name),
374
+ h('span', { className: 'dsh-safebox-tag' }, t(`kind.${item.kind}`)),
375
+ h('div', { className: 'dsh-safebox-actions' },
376
+ h('button', { type: 'button', className: 'dsh-safebox-button', disabled: busy, onClick: () => { setEditor({ mode: 'edit', item }); setDeleteId(null); setError(''); setNotice('') } }, t('edit')),
377
+ h('button', { type: 'button', className: 'dsh-safebox-button dsh-safebox-danger', disabled: busy, onClick: () => { setDeleteId(item.id); setEditor(null); setError(''); setNotice('') } }, t('remove')))),
378
+ h('dl', { className: 'dsh-safebox-meta' },
379
+ h('dt', null, t('reference')), h('dd', { className: 'dsh-safebox-ref' }, item.ref),
380
+ item.username && h(React.Fragment, { key: 'username' }, h('dt', null, t('username')), h('dd', null, item.username)),
381
+ h('dt', null, t('target')), h('dd', null, item.target || t('noTarget'))),
382
+ deleteId === item.id && h('div', { className: 'dsh-safebox-confirm' },
383
+ h('h3', { className: 'dsh-safebox-confirmTitle' }, t('deleteTitle')),
384
+ h('p', { className: 'dsh-safebox-confirmText' }, t('deleteDescription')),
385
+ h('div', { className: 'dsh-safebox-actions' },
386
+ h('button', { type: 'button', className: 'dsh-safebox-button', disabled: busy, onClick: () => setDeleteId(null) }, t('cancel')),
387
+ h('button', { type: 'button', className: 'dsh-safebox-button dsh-safebox-danger', disabled: busy, onClick: () => remove(item) }, t('confirmDelete')))))))))
388
+ }
389
+
390
+ return {
391
+ inject: ['slots', 'locale', 'configForms', 'remote', 'remote.credentials'],
392
+ apply(ctx) {
393
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'safebox: dictionaries')
394
+ const t = ctx.locale.bind(NS)
395
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
396
+ name: 'settings.section', id: ENTRY_ID, order: 60,
397
+ label: () => t('nav'), locale: NS,
398
+ inject: () => ({ form: ctx.configForms.get(ENTRY_ID), credentials: ctx.remote.credentials }),
399
+ }, SafeboxSection))
400
+ ctx.slots.inject('settings.section.icon', () => ctx.slots.register({
401
+ name: 'settings.section.icon', id: ENTRY_ID, locale: NS,
402
+ }, LockIcon))
403
+ },
404
+ }
405
+ },
406
+ })
@@ -0,0 +1,13 @@
1
+ [
2
+ {
3
+ "insert": [
4
+ {
5
+ "id": "safebox",
6
+ "name": "@jiangzhenguo/dsh-safebox",
7
+ "config": {
8
+ "items": []
9
+ }
10
+ }
11
+ ]
12
+ }
13
+ ]
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <rect x="7" y="7" width="50" height="50" rx="14" fill="#315C85"/>
3
+ <rect x="17" y="28" width="30" height="23" rx="5" stroke="#FFF" stroke-width="3"/>
4
+ <path d="M23 28v-6a9 9 0 0 1 18 0v6" stroke="#FFF" stroke-width="3" stroke-linecap="round"/>
5
+ <circle cx="32" cy="38" r="3" fill="#FFF"/>
6
+ <path d="M32 41v4" stroke="#FFF" stroke-width="3" stroke-linecap="round"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,79 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+
3
+ const itemSchema = z.object({
4
+ id: z.string().pattern(/^item-[a-z0-9-]+$/),
5
+ name: z.string().min(1).max(200),
6
+ kind: z.union([z.const('account'), z.const('api-token'), z.const('secret-text')]),
7
+ username: z.string().max(500),
8
+ target: z.string().max(2000),
9
+ ref: z.string().pattern(/^SAFEBOX_[A-Z0-9_]+$/),
10
+ createdAt: z.number(),
11
+ updatedAt: z.number(),
12
+ })
13
+
14
+ /** Only non-secret metadata is kept in configuration; values use the credential store. */
15
+ export const Config = z.object({
16
+ items: z.array(itemSchema).default([]).volatile(),
17
+ })
18
+
19
+ export const inject = ['tools']
20
+
21
+ const output = {
22
+ schema: {
23
+ type: 'object',
24
+ additionalProperties: false,
25
+ properties: {
26
+ items: {
27
+ type: 'array',
28
+ items: {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ properties: {
32
+ id: { type: 'string' },
33
+ name: { type: 'string' },
34
+ kind: { type: 'string', enum: ['account', 'api-token', 'secret-text'] },
35
+ username: { type: 'string' },
36
+ target: { type: 'string' },
37
+ ref: { type: 'string' },
38
+ createdAt: { type: 'number' },
39
+ updatedAt: { type: 'number' },
40
+ },
41
+ required: ['id', 'name', 'kind', 'username', 'target', 'ref', 'createdAt', 'updatedAt'],
42
+ },
43
+ },
44
+ },
45
+ required: ['items'],
46
+ },
47
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
48
+ }
49
+
50
+ function publicItem(item) {
51
+ return {
52
+ id: item.id,
53
+ name: item.name,
54
+ kind: item.kind,
55
+ username: item.username,
56
+ target: item.target,
57
+ ref: item.ref,
58
+ createdAt: item.createdAt,
59
+ updatedAt: item.updatedAt,
60
+ }
61
+ }
62
+
63
+ export function apply(ctx, config) {
64
+ // Keep the hand-built management section instead of an auto-generated Config page.
65
+ ctx.inject(['settings'], (child) => {
66
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
67
+ })
68
+
69
+ ctx.effect(() => ctx.tools.register({
70
+ name: 'safebox_list',
71
+ description: 'List Safebox secret references and their user-confirmed usage targets. Returns metadata only, never secret values. Creating or importing secrets and adding usage targets are user-only actions in Settings.',
72
+ parameters: {},
73
+ output,
74
+ execute: () => Promise.resolve({
75
+ items: config.items.get().map(publicItem),
76
+ }),
77
+ isConcurrencySafe: () => true,
78
+ }), 'safebox: read-only reference tool')
79
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Safebox secrets",
4
+ "description": "Manage local account passwords, API tokens, and secret text in Settings; the agent receives references, never secret values."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Safebox 密钥管理",
4
+ "description": "在设置中管理本机账号密码、API Token 和加密文本;Agent 只能读取密钥引用,不能读取秘密值。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@jiangzhenguo/dsh-safebox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.2",
4
+ "license": "MIT",
5
+ "description": "A DSH settings panel for local secret metadata and agent-safe references",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20",
9
+ "dsh": ">=0.2.2-rc.1 <0.3.0-0"
10
+ },
11
+ "keywords": [
12
+ "dsh",
13
+ "dsh-plugin",
14
+ "deepseek-harness",
15
+ "secrets",
16
+ "credentials",
17
+ "security",
18
+ "password-manager",
19
+ "local-first"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/jiangzhenguo/dsh-safebox.git"
24
+ },
25
+ "exports": {
26
+ ".": "./index.js",
27
+ "./client": "./client.js",
28
+ "./locale/*.json": "./locale/*.json",
29
+ "./icon": "./icon.svg"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "client.js",
34
+ "cordis.patch.yml",
35
+ "locale/*.json",
36
+ "icon.svg",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "dependencies": {
41
+ "@deepseek-ai/schemastery": "~3.19.0"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "immediately": true,
50
+ "inject": [
51
+ "@deepseek-ai/dsh-api-remotes",
52
+ "@deepseek-ai/dsh-client-ui-settings",
53
+ "@deepseek-ai/dsh-client-ui-settings-general"
54
+ ]
55
+ }
56
+ },
57
+ "scripts": {
58
+ "check": "node --check index.js && node --check client.js && node scripts/verify.mjs",
59
+ "test": "node --test test/*.test.mjs",
60
+ "verify": "pnpm run check && pnpm run test"
61
+ }
6
62
  }