fix: 修正凭据名称避免 DSH 重启失败

This commit is contained in:
cneicy committed 2026-10-05 03:58:46 +08:00
1 parent 108e957c64
commit 7e2ec28b80
4 files changed
+57 -6

No files matched your search

+11
View File
@@ -141,6 +141,17 @@ git+https://git.crash.work/cneicy/dsh-imagegen.git
- 在 DSH「插件」页面停用或卸载 `dsh-imagegen-local`。 - 在 DSH「插件」页面停用或卸载 `dsh-imagegen-local`。
- 卸载不会自动删除已保存的图片和凭据。如需清理密钥,请先在生图设置里清除对应地址的密钥,再卸载。 - 卸载不会自动删除已保存的图片和凭据。如需清理密钥,请先在生图设置里清除对应地址的密钥,再卸载。
### 1.0.1 凭据兼容修复(从 1.0.0 升级必读)
1.0.0 曾将凭据名称写成 `dsh-imagegen:<SHA-256>`,不符合 DSH 的 `scope/id` 校验规则,可能导致凭据服务在下次启动时失败。1.0.1 统一使用 `imagegen-local/endpoint-<SHA-256>`;`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 以及账号是否有该模型的权限。 **出现 HTTP 401 / 403**:检查 API Key、Base URL 以及账号是否有该模型的权限。
+3 -1
View File
@@ -12,7 +12,9 @@ const dataRoot = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'imagegen
const settingsPath = join(dataRoot, 'settings.json'); const settingsPath = join(dataRoot, 'settings.json');
const outputs = join(dataRoot, 'outputs'); const outputs = join(dataRoot, 'outputs');
const initial = { baseUrl: 'https://api.openai.com/v1', models: ['gpt-image-2', 'gpt-image-1.5'], defaultModel: '' }; 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 <scope>/<id>, 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 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 schema = { type: 'object', additionalProperties: true };
const text = value => ({ type: 'text', text: JSON.stringify(value, null, 2) }); const text = value => ({ type: 'text', text: JSON.stringify(value, null, 2) });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-imagegen-local", "name": "dsh-imagegen-local",
"version": "1.0.0", "version": "1.0.1",
"description": "生图:自定义 Images API、显式模型选择及对话生图工具", "description": "生图:自定义 Images API、显式模型选择及对话生图工具",
"type": "module", "type": "module",
"main": "./lib/index.mjs", "main": "./lib/index.mjs",
+42 -4
View File
@@ -5,6 +5,12 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, dirname, basename } from 'node:path'; 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'); const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jVv8AAAAASUVORK5CYII=', 'base64');
test('native host integration using only a local image API', async t => { 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); } }, tools: { register(tool) { tools.set(tool.name, tool); return () => tools.delete(tool.name); } },
credentials: { credentials: {
async readRecord(key) { return records.get(key); }, async readRecord(key) { validateCredentialName(key); return records.get(key); },
async modifyRecord(key, change) { const value = await change(records.get(key)); records.set(key, value); return value; }, async modifyRecord(key, change) { validateCredentialName(key); const value = await change(records.get(key)); records.set(key, value); return value; },
async deleteRecord(key) { records.delete(key); }, async deleteRecord(key) { validateCredentialName(key); records.delete(key); },
}, },
attachments: { async saveImage(image) { attachments: { async saveImage(image) {
assert.deepEqual(Buffer.from(image.data), PNG); 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.ok(!JSON.stringify(value).includes(key));
assert.equal(records.size, 1); assert.equal(records.size, 1);
const [credentialName, credential] = [...records][0]; 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 }); assert.deepEqual(credential, { kind: 'api-key', key });
const disk = await readFile(join(home, 'imagegen', 'settings.json'), 'utf8'); const disk = await readFile(join(home, 'imagegen', 'settings.json'), 'utf8');
assert.deepEqual(JSON.parse(disk), settings); 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(!JSON.stringify(status).includes(key));
assert.ok(!(await (await call('/api/imagegen/settings')).text()).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 () => { await t.test('lists models using the local authenticated API', async () => {
const response = await call('/api/imagegen/models', 'POST'); const response = await call('/api/imagegen/models', 'POST');
assert.equal(response.status, 200); 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.deepEqual(await readFile(value.images[0].path), PNG);
assert.ok(!JSON.stringify(value).includes(key)); 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 () => { 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']) { 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); assert.equal((await call(`/api/imagegen/file?name=${encodeURIComponent(filename)}`)).status, 404);