From 7e2ec28b8046fb5f34d3abcc441b8713899713eb Mon Sep 17 00:00:00 2001 From: cneicy Date: Mon, 5 Oct 2026 03:58:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E9=81=BF=E5=85=8D=20DSH=20=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +++++++++++ lib/index.mjs | 4 +++- package.json | 2 +- test/host.test.mjs | 46 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 962288a..9c8050b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,17 @@ git+https://git.crash.work/cneicy/dsh-imagegen.git - 在 DSH「插件」页面停用或卸载 `dsh-imagegen-local`。 - 卸载不会自动删除已保存的图片和凭据。如需清理密钥,请先在生图设置里清除对应地址的密钥,再卸载。 +### 1.0.1 凭据兼容修复(从 1.0.0 升级必读) + +1.0.0 曾将凭据名称写成 `dsh-imagegen:`,不符合 DSH 的 `scope/id` 校验规则,可能导致凭据服务在下次启动时失败。1.0.1 统一使用 `imagegen-local/endpoint-`;`endpoint-` 保证 ID 始终以字母开头,scope 与注册的 Host 插件名称一致。读、写、删除都使用同一格式。 + +- **DSH 已能启动**:先更新插件并重启 DSH,再在「设置 → 生图」重新保存 API Key。旧版配置的 Base URL、模型列表和原图不受影响。 +- **DSH 因旧记录无法启动**:先退出 DSH,安全备份 `$DSH_HOME/.credentials.yaml`,只移出 `records` 下本插件的旧条目(`dsh-imagegen:<哈希>`,或曾手动改成的 `dsh-imagegen/<哈希>`),保留其他凭据和 YAML 结构;启动后更新插件,再重新保存 API Key。备份含密钥,请勿上传。 +- 不要仅替换冒号:十六进制哈希可能以数字开头,仍不符合 ID 规则。 +- 插件不会自动读取备份、恢复被移出的旧密钥,或直接改写全局凭据文件。凭据服务先于插件加载,启动被旧记录阻断时,插件自身无法执行迁移。 + +回归测试现在在每次凭据读写、删除及持久化重载时校验名称,并覆盖哈希首字符的全部 16 种情况。 + ## 常见问题 **出现 HTTP 401 / 403**:检查 API Key、Base URL 以及账号是否有该模型的权限。 diff --git a/lib/index.mjs b/lib/index.mjs index 1c20167..1a1799f 100644 --- a/lib/index.mjs +++ b/lib/index.mjs @@ -12,7 +12,9 @@ const dataRoot = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'imagegen const settingsPath = join(dataRoot, 'settings.json'); const outputs = join(dataRoot, 'outputs'); const initial = { baseUrl: 'https://api.openai.com/v1', models: ['gpt-image-2', 'gpt-image-1.5'], defaultModel: '' }; -const keyFor = base => `dsh-imagegen:${createHash('sha256').update(base).digest('hex')}`; +// DSH CredentialKey requires /, each matching [a-z][a-z0-9-]*. +// Use the registered plugin name as scope and prefix the digest: hex may start with a digit. +const keyFor = base => `${name}/endpoint-${createHash('sha256').update(base).digest('hex')}`; const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }); const schema = { type: 'object', additionalProperties: true }; const text = value => ({ type: 'text', text: JSON.stringify(value, null, 2) }); diff --git a/package.json b/package.json index 1999e56..f529d27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-imagegen-local", - "version": "1.0.0", + "version": "1.0.1", "description": "生图:自定义 Images API、显式模型选择及对话生图工具", "type": "module", "main": "./lib/index.mjs", diff --git a/test/host.test.mjs b/test/host.test.mjs index 7c137dd..c0e8003 100644 --- a/test/host.test.mjs +++ b/test/host.test.mjs @@ -5,6 +5,12 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, dirname, basename } from 'node:path'; +// Match DSH credentials credentialKey/parseCredentialKey admission rules. +// Validate at every service boundary AND on reload, unlike the original permissive mock. +function validateCredentialName(key) { + assert.match(key, /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/); +} + const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jVv8AAAAASUVORK5CYII=', 'base64'); test('native host integration using only a local image API', async t => { @@ -47,9 +53,9 @@ test('native host integration using only a local image API', async t => { } } }, tools: { register(tool) { tools.set(tool.name, tool); return () => tools.delete(tool.name); } }, credentials: { - async readRecord(key) { return records.get(key); }, - async modifyRecord(key, change) { const value = await change(records.get(key)); records.set(key, value); return value; }, - async deleteRecord(key) { records.delete(key); }, + async readRecord(key) { validateCredentialName(key); return records.get(key); }, + async modifyRecord(key, change) { validateCredentialName(key); const value = await change(records.get(key)); records.set(key, value); return value; }, + async deleteRecord(key) { validateCredentialName(key); records.delete(key); }, }, attachments: { async saveImage(image) { assert.deepEqual(Buffer.from(image.data), PNG); @@ -90,7 +96,7 @@ test('native host integration using only a local image API', async t => { assert.ok(!JSON.stringify(value).includes(key)); assert.equal(records.size, 1); const [credentialName, credential] = [...records][0]; - assert.match(credentialName, /^dsh-imagegen:[0-9a-f]{64}$/); + assert.match(credentialName, /^imagegen-local\/endpoint-[0-9a-f]{64}$/); assert.deepEqual(credential, { kind: 'api-key', key }); const disk = await readFile(join(home, 'imagegen', 'settings.json'), 'utf8'); assert.deepEqual(JSON.parse(disk), settings); @@ -100,6 +106,18 @@ test('native host integration using only a local image API', async t => { assert.ok(!JSON.stringify(status).includes(key)); assert.ok(!(await (await call('/api/imagegen/settings')).text()).includes(key)); }); + await t.test('saved credentials survive strict reload and host reinitialization', async () => { + const persisted = JSON.stringify({ version: 1, records: Object.fromEntries(records) }); + records.clear(); + for (const [address, value] of Object.entries(JSON.parse(persisted).records)) { + validateCredentialName(address); + records.set(address, value); + } + listeners.get('dispose')(); + routes.clear(); tools.clear(); + await host.apply(ctx); + assert.equal((await tools.get('imagegen_status').execute()).configured, true); + }); await t.test('lists models using the local authenticated API', async () => { const response = await call('/api/imagegen/models', 'POST'); assert.equal(response.status, 200); @@ -157,6 +175,26 @@ test('native host integration using only a local image API', async t => { assert.deepEqual(await readFile(value.images[0].path), PNG); assert.ok(!JSON.stringify(value).includes(key)); }); + await t.test('all digest prefixes are valid and clearing only removes the selected endpoint', async () => { + const originalRecords = new Map(records); + const prefixes = new Set(); + for (let i = 0; prefixes.size < 16 && i < 512; i++) { + const endpoint = { ...settings, baseUrl: `https://example.com/v1/provider-${i}` }; + const response = await call('/api/imagegen/settings', 'POST', { ...endpoint, apiKey: 'test-only-temporary-key' }); + assert.equal(response.status, 200); + assert.equal((await response.json()).configured, true); + const address = [...records.keys()].find(item => !originalRecords.has(item)); + validateCredentialName(address); + prefixes.add(address.split('endpoint-')[1][0]); + assert.equal((await (await call('/api/imagegen/settings', 'POST', endpoint)).json()).configured, true); + const cleared = await call('/api/imagegen/settings', 'POST', { ...endpoint, clearKey: true }); + assert.equal(cleared.status, 200); + assert.equal((await cleared.json()).configured, false); + assert.deepEqual(records, originalRecords); + } + assert.equal(prefixes.size, 16, 'exercise every hexadecimal leading character, including 0–9'); + await call('/api/imagegen/settings', 'POST', settings); + }); await t.test('file traversal, absolute paths and missing files return 404', async () => { for (const filename of ['../settings.json', '..\\settings.json', join(home, 'imagegen', 'settings.json'), '/etc/passwd', '', '123-00000000-0000-0000-0000-000000000000.png']) { assert.equal((await call(`/api/imagegen/file?name=${encodeURIComponent(filename)}`)).status, 404);