@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.1
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/CHANGELOG.md +81 -1
- package/README.en.md +103 -312
- package/README.md +100 -310
- package/bin/dsh-iris.js +333 -65
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +10 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +2 -2
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +39 -14
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +3 -1
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +17 -7
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +53 -2
- package/lib/adapters.js +20 -11
- package/lib/application-runtime.js +34 -7
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +346 -53
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +16 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +29 -12
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +38 -64
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +105 -10
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +73 -29
- package/lib/index.js +37 -17
- package/lib/local-api-projection.js +10 -5
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +769 -67
- package/lib/local-creation.js +89 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +22 -9
- package/lib/skill-installation.js +23 -11
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +20 -5
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/user_guide.md +5 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Same-origin transport. Writes use named media methods or a finite management path.
|
|
2
|
+
const RUN = /^run_[a-f0-9]{24}$/, ARTIFACT = /^artifact_[a-f0-9]{24}$/, OPERATION = /^op_[a-f0-9]{64}$/;
|
|
3
|
+
const KEY = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
4
|
+
function id(value, pattern) { if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError('本地操作标识无效'); return value; }
|
|
5
|
+
export class WorkbenchHttpError extends Error {
|
|
6
|
+
constructor(status, error) {
|
|
7
|
+
super(error?.message || '本地服务返回了无法读取的响应');
|
|
8
|
+
this.name = 'WorkbenchHttpError'; this.status = status;
|
|
9
|
+
this.code = error?.code; this.requestId = error?.requestId;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function redeemLoginLink(ticket, signal) {
|
|
14
|
+
const response = await fetch('/api/v0/workbench/login', { method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket }),
|
|
16
|
+
credentials: 'omit', cache: 'no-store', redirect: 'error',
|
|
17
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
|
18
|
+
const value = await response.json();
|
|
19
|
+
if (!response.ok) throw new WorkbenchHttpError(response.status, value.error);
|
|
20
|
+
if (value.contractVersion !== 0 || typeof value.accessToken !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.accessToken)) throw new TypeError('不支持的登录响应');
|
|
21
|
+
return value.accessToken;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createReadClient(token, { onUnauthorized = () => {} } = {}) {
|
|
25
|
+
const lifetime = new AbortController(), reads = [], media = [];
|
|
26
|
+
let active = 0;
|
|
27
|
+
function drain() {
|
|
28
|
+
while (active < 2 && (reads.length || media.length)) {
|
|
29
|
+
const job = (reads.length ? reads : media).shift(); active++;
|
|
30
|
+
run(job).then(job.resolve, job.reject).finally(() => { active--; drain(); });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function run({ pathname, binary, download, signal, value, body, key, method = 'GET', mediaType = 'application/json' }) {
|
|
34
|
+
const combined = AbortSignal.any([lifetime.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])]);
|
|
35
|
+
combined.throwIfAborted();
|
|
36
|
+
const response = await fetch(pathname, { method, headers: { Authorization: 'Bearer ' + token,
|
|
37
|
+
...(method === 'POST' ? { 'Content-Type': mediaType } : {}), ...(key ? { 'Idempotency-Key': key } : {}) },
|
|
38
|
+
credentials: 'omit', cache: 'no-store', redirect: 'error', signal: combined,
|
|
39
|
+
...(method === 'POST' ? { body: body ?? JSON.stringify(value) } : {}) });
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const body = await response.json();
|
|
42
|
+
const error = new WorkbenchHttpError(response.status, body.error);
|
|
43
|
+
if (response.status === 401 && !lifetime.signal.aborted) onUnauthorized();
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (download) return { blob: await response.blob(), filename: /^attachment; filename="([^"]+)"$/.exec(response.headers.get('content-disposition') || '')?.[1] };
|
|
47
|
+
return binary ? response.blob() : response.json();
|
|
48
|
+
}
|
|
49
|
+
function get(pathname, { binary = false, download = false, signal } = {}) {
|
|
50
|
+
if (!/^\/api\/v0\//.test(pathname) || pathname.includes('#')) throw new TypeError('仅允许本地 API 路径');
|
|
51
|
+
if (lifetime.signal.aborted) return Promise.reject(lifetime.signal.reason);
|
|
52
|
+
return new Promise((resolve, reject) => { (binary || download ? media : reads).push({ pathname, binary, download, signal, resolve, reject }); drain(); });
|
|
53
|
+
}
|
|
54
|
+
const post = (pathname, value, options = {}) => run({ pathname, value, method: 'POST', ...options });
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
get,
|
|
57
|
+
callPlugin({ key, ...value }, { signal } = {}) { return post('/api/v0/plugin-operations', value, { key: id(key, KEY), signal }); },
|
|
58
|
+
lookupCreation(kind, key, options) {
|
|
59
|
+
if (!['image-import', 'flow-create', 'generation-create', 'audio-import', 'speech-create', 'transcription-create'].includes(kind)) throw new TypeError('创建类型无效');
|
|
60
|
+
return get('/api/v0/creation-requests?' + new URLSearchParams({ kind, key: id(key, KEY) }), options);
|
|
61
|
+
},
|
|
62
|
+
lookupOperation(key, options) { return get('/api/v0/operations?' + new URLSearchParams({ key: id(key, KEY) }), options); },
|
|
63
|
+
getOperation(operationId, options) { return get('/api/v0/operations/' + id(operationId, OPERATION), options); },
|
|
64
|
+
getRun(runId, options) { return get('/api/v0/runs/' + id(runId, RUN), options); },
|
|
65
|
+
uploadImage(image, { key, signal } = {}) {
|
|
66
|
+
if (!(image instanceof Blob) || !['image/png', 'image/jpeg', 'image/webp'].includes(image.type)) throw new TypeError('需要 PNG/JPEG/WebP 图片字节');
|
|
67
|
+
return post('/api/v0/images', undefined, { body: image, mediaType: image.type, key: id(key, KEY), signal });
|
|
68
|
+
},
|
|
69
|
+
createRun(value, { key, signal } = {}) { return post('/api/v0/runs', value, { key: id(key, KEY), signal }); },
|
|
70
|
+
createGeneration(value, { key, signal } = {}) { return post('/api/v0/generations', value, { key: id(key, KEY), signal }); },
|
|
71
|
+
uploadAudio(audio, { key, signal } = {}) {
|
|
72
|
+
if (!(audio instanceof Blob) || !['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'].includes(audio.type)) throw new TypeError('需要WAV/MP3/OGG/FLAC音频');
|
|
73
|
+
return post('/api/v0/audio', undefined, { body: audio, mediaType: audio.type, key: id(key, KEY), signal });
|
|
74
|
+
},
|
|
75
|
+
createSpeech(value, { key, signal } = {}) { return post('/api/v0/speeches', value, { key: id(key, KEY), signal }); },
|
|
76
|
+
createTranscription(value, { key, signal } = {}) { return post('/api/v0/transcriptions', value, { key: id(key, KEY), signal }); },
|
|
77
|
+
continueRun(runId, expectedRevision, { key, signal } = {}) {
|
|
78
|
+
return post('/api/v0/runs/' + id(runId, RUN) + '/continue', { expectedRevision }, { key: id(key, KEY), signal });
|
|
79
|
+
},
|
|
80
|
+
stopOperation(operationId, { signal } = {}) { return post('/api/v0/operations/' + id(operationId, OPERATION) + '/cancel', {}, { signal }); },
|
|
81
|
+
selectRun(runId, artifactId, expectedRevision, { signal } = {}) {
|
|
82
|
+
return post('/api/v0/runs/' + id(runId, RUN) + '/selection', { artifactId: id(artifactId, ARTIFACT), expectedRevision }, { signal });
|
|
83
|
+
},
|
|
84
|
+
postManagement(pathname, value) {
|
|
85
|
+
if (!/^\/api\/v0\/connection-management\/(?:templates|connections(?:\/[a-f0-9-]{36}\/(?:update|revoke|delete|export|skill))?)$/.test(pathname)) {
|
|
86
|
+
throw new TypeError('仅允许连接管理操作');
|
|
87
|
+
}
|
|
88
|
+
return post(pathname, value);
|
|
89
|
+
},
|
|
90
|
+
postProvider(action, value, { signal } = {}) {
|
|
91
|
+
if (!['save', 'discover', 'workflow-check', 'policies', 'enabled', 'remove', 'impact', 'check', 'monitor'].includes(action)) throw new TypeError('仅允许明确的供应商管理操作');
|
|
92
|
+
return post('/api/v0/provider-management/' + action, value, { signal });
|
|
93
|
+
},
|
|
94
|
+
close() { lifetime.abort(); for (const job of [...reads.splice(0), ...media.splice(0)]) job.reject(lifetime.signal.reason); }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Loaded only by a password-mode shell. Stored authority is a browser session, never the owner control key.
|
|
2
|
+
import { WorkbenchHttpError } from './transport.js';
|
|
3
|
+
|
|
4
|
+
const TAB = 'iris.workbench.password-session.v0', DEVICE = 'iris.workbench.remembered-session.v0', SETUP = 'iris.workbench.setup.v0';
|
|
5
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/, ID = /^[a-f0-9]{32}$/;
|
|
6
|
+
const $ = selector => document.querySelector(selector);
|
|
7
|
+
|
|
8
|
+
export function createWorkbenchLogin({ connect, disconnect, feedback, describe }) {
|
|
9
|
+
let status, current, controller, generation = 0, busy = false, storageFailed = false;
|
|
10
|
+
const validStatus = value => value?.contractVersion === 0 && value.mode === 'password' && typeof value.configured === 'boolean' && ID.test(value.authId);
|
|
11
|
+
function storage(area, method, value) {
|
|
12
|
+
try { return window[area][method](area === 'localStorage' ? DEVICE : value?.setup ? SETUP : TAB, value?.text); }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (!['SecurityError', 'QuotaExceededError', 'InvalidStateError'].includes(error.name)) throw error;
|
|
15
|
+
storageFailed = true; return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function notice() {
|
|
19
|
+
$('#session-notice').hidden = !storageFailed;
|
|
20
|
+
if (storageFailed) $('#session-notice').textContent = '浏览器无法保存部分登录信息。本次仍可使用,再次打开时可能需要输入访问口令。';
|
|
21
|
+
}
|
|
22
|
+
function cached(area) {
|
|
23
|
+
const text = storage(area, 'getItem'); if (!text) return null;
|
|
24
|
+
let value;
|
|
25
|
+
try { if (text.length > 512) throw new Error('oversized'); value = JSON.parse(text); }
|
|
26
|
+
catch (_) { storage(area, 'removeItem'); return null; }
|
|
27
|
+
if (!value || Object.keys(value).sort().join(',') !== 'accessToken,authId,contractVersion,expiresAt,remember'
|
|
28
|
+
|| value.contractVersion !== 0 || value.authId !== status.authId || !TOKEN.test(value.accessToken || '')
|
|
29
|
+
|| typeof value.remember !== 'boolean' || !Number.isFinite(Date.parse(value.expiresAt)) || Date.parse(value.expiresAt) <= Date.now()) {
|
|
30
|
+
storage(area, 'removeItem'); return null;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function saveSession(value) {
|
|
35
|
+
if (!value || value.contractVersion !== 0 || value.authId !== status.authId || !TOKEN.test(value.accessToken || '')
|
|
36
|
+
|| !Number.isFinite(Date.parse(value.expiresAt)) || typeof value.remember !== 'boolean') throw new TypeError('不支持的工作台登录响应');
|
|
37
|
+
current = value;
|
|
38
|
+
storage('sessionStorage', 'setItem', { text: JSON.stringify(value) });
|
|
39
|
+
if (value.remember) storage('localStorage', 'setItem', { text: JSON.stringify(value) });
|
|
40
|
+
else storage('localStorage', 'removeItem');
|
|
41
|
+
storage('sessionStorage', 'removeItem', { setup: true }); notice();
|
|
42
|
+
}
|
|
43
|
+
function setupTicket() {
|
|
44
|
+
const text = storage('sessionStorage', 'getItem', { setup: true });
|
|
45
|
+
if (!text) return null;
|
|
46
|
+
let value;
|
|
47
|
+
try { if (text.length > 256) throw new Error('oversized'); value = JSON.parse(text); } catch (_) { /* Clear invalid temporary setup state below. */ }
|
|
48
|
+
if (value?.authId === status.authId && TOKEN.test(value.ticket || '')) return value.ticket;
|
|
49
|
+
storage('sessionStorage', 'removeItem', { setup: true }); return null;
|
|
50
|
+
}
|
|
51
|
+
function render() {
|
|
52
|
+
const setup = status?.configured === false;
|
|
53
|
+
$('#password-login').hidden = false;
|
|
54
|
+
$('#password-confirm-row').hidden = !setup;
|
|
55
|
+
$('#password-confirm').required = setup;
|
|
56
|
+
$('#password').autocomplete = setup ? 'new-password' : 'current-password';
|
|
57
|
+
$('#password-submit').textContent = setup ? '设置口令并进入' : '进入工作台';
|
|
58
|
+
$('#password-submit').disabled = busy || !status || setup && !setupTicket();
|
|
59
|
+
$('#password').disabled = busy || !status || setup && !setupTicket();
|
|
60
|
+
$('#password-confirm').disabled = busy;
|
|
61
|
+
$('#password-retry').hidden = !!status;
|
|
62
|
+
$('#password-hint').textContent = !status ? '正在读取工作台登录状态…' : setup
|
|
63
|
+
? setupTicket() ? '为这个工作台设置一个访问口令。之后用普通地址即可登录。'
|
|
64
|
+
: '首次使用:请打开启动时给出的设置入口,或在本机运行 web login 获取设置入口。'
|
|
65
|
+
: '用你的访问口令进入。记住此设备后,可跨正常重启继续使用。';
|
|
66
|
+
notice();
|
|
67
|
+
}
|
|
68
|
+
async function request(route, value, signal, token) {
|
|
69
|
+
const response = await fetch('/api/v0/workbench/' + route, { method: value ? 'POST' : 'GET',
|
|
70
|
+
credentials: 'omit', cache: 'no-store', redirect: 'error',
|
|
71
|
+
headers: { ...(value ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
|
72
|
+
...(value ? { body: JSON.stringify(value) } : {}), signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
|
73
|
+
const result = await response.json();
|
|
74
|
+
if (!response.ok) throw new WorkbenchHttpError(response.status, result.error);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
async function open() {
|
|
78
|
+
const turn = ++generation; controller?.abort(); controller = new AbortController();
|
|
79
|
+
const signal = controller.signal; busy = true;
|
|
80
|
+
$('#login-help').hidden = true; $('#paste-panel').hidden = true;
|
|
81
|
+
$('#connection-title').textContent = '欢迎回到 Iris'; render();
|
|
82
|
+
const fragment = location.hash.startsWith('#setup=') ? location.hash.slice(7) : null;
|
|
83
|
+
if (fragment !== null || location.hash.startsWith('#login=')) history.replaceState(null, '', location.pathname + location.search);
|
|
84
|
+
try {
|
|
85
|
+
const value = await request('auth', undefined, signal);
|
|
86
|
+
if (turn !== generation) return;
|
|
87
|
+
if (!validStatus(value)) throw new TypeError('不支持的工作台认证响应');
|
|
88
|
+
status = value;
|
|
89
|
+
if (fragment !== null && !value.configured) {
|
|
90
|
+
if (!TOKEN.test(fragment)) throw new Error('首次设置链接无效,请在本机运行 web login 获取当前入口。');
|
|
91
|
+
storage('sessionStorage', 'setItem', { setup: true, text: JSON.stringify({ authId: status.authId, ticket: fragment }) });
|
|
92
|
+
}
|
|
93
|
+
if (!value.configured) {
|
|
94
|
+
storage('sessionStorage', 'removeItem'); storage('localStorage', 'removeItem'); current = null;
|
|
95
|
+
} else {
|
|
96
|
+
storage('sessionStorage', 'removeItem', { setup: true });
|
|
97
|
+
current = cached('sessionStorage') || cached('localStorage');
|
|
98
|
+
if (current) await connect(current.accessToken);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) { if (turn === generation) feedback(describe(error), true); }
|
|
101
|
+
finally { if (turn === generation) { busy = false; render(); } }
|
|
102
|
+
}
|
|
103
|
+
function forget() {
|
|
104
|
+
generation++; controller?.abort(); current = null;
|
|
105
|
+
storage('sessionStorage', 'removeItem'); storage('localStorage', 'removeItem');
|
|
106
|
+
$('#password').value = ''; $('#password-confirm').value = ''; busy = false; render();
|
|
107
|
+
}
|
|
108
|
+
async function logout() {
|
|
109
|
+
if (busy || !current) return;
|
|
110
|
+
const turn = ++generation, token = current.accessToken;
|
|
111
|
+
controller?.abort(); controller = new AbortController(); busy = true; $('#disconnect').disabled = true;
|
|
112
|
+
try {
|
|
113
|
+
await request('logout', {}, controller.signal, token);
|
|
114
|
+
if (turn !== generation) return;
|
|
115
|
+
forget(); disconnect(); feedback('已退出。输入访问口令即可重新进入。'); $('#password').focus();
|
|
116
|
+
} catch (error) { if (turn === generation) feedback('退出尚未确认,请重试。' + describe(error), true); }
|
|
117
|
+
finally { if (turn === generation) busy = false; $('#disconnect').disabled = false; }
|
|
118
|
+
}
|
|
119
|
+
$('#password-form').addEventListener('submit', async event => {
|
|
120
|
+
event.preventDefault(); if (busy || !status) return;
|
|
121
|
+
const setup = !status.configured, password = $('#password').value;
|
|
122
|
+
if (setup && password !== $('#password-confirm').value) { feedback('两次输入的访问口令不一致。', true); return; }
|
|
123
|
+
const ticket = setup ? setupTicket() : undefined;
|
|
124
|
+
if (setup && !ticket) { feedback('请打开本机启动器给出的首次设置入口。', true); return; }
|
|
125
|
+
const turn = ++generation; controller?.abort(); controller = new AbortController(); busy = true; render();
|
|
126
|
+
const remember = $('#password-remember').checked;
|
|
127
|
+
$('#password').value = ''; $('#password-confirm').value = ''; feedback(setup ? '正在保存访问口令…' : '正在登录…');
|
|
128
|
+
try {
|
|
129
|
+
const result = await request(setup ? 'setup' : 'password-login', { password, remember, ...(setup ? { ticket } : {}) }, controller.signal);
|
|
130
|
+
if (turn !== generation) return;
|
|
131
|
+
status = { ...status, configured: true }; saveSession(result);
|
|
132
|
+
await connect(result.accessToken);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (turn !== generation) return;
|
|
135
|
+
if (setup) {
|
|
136
|
+
try {
|
|
137
|
+
const latest = await request('auth', undefined, controller.signal);
|
|
138
|
+
if (turn !== generation) return;
|
|
139
|
+
if (!validStatus(latest) || latest.authId !== status.authId) throw new TypeError('不支持的工作台认证响应');
|
|
140
|
+
status = latest;
|
|
141
|
+
if (latest.configured) {
|
|
142
|
+
storage('sessionStorage', 'removeItem', { setup: true });
|
|
143
|
+
feedback('访问口令已设置。请直接输入口令登录工作台。'); return;
|
|
144
|
+
}
|
|
145
|
+
} catch (verification) {
|
|
146
|
+
if (turn === generation) feedback(describe(error) + ';设置结果暂时无法核对:' + describe(verification), true);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
feedback(describe(error), true);
|
|
151
|
+
}
|
|
152
|
+
finally { if (turn === generation) { busy = false; render(); } }
|
|
153
|
+
});
|
|
154
|
+
$('#password-show').addEventListener('click', () => {
|
|
155
|
+
const show = $('#password').type === 'password'; $('#password').type = show ? 'text' : 'password';
|
|
156
|
+
$('#password-show').textContent = show ? '隐藏' : '显示'; $('#password-show').setAttribute('aria-pressed', String(show));
|
|
157
|
+
});
|
|
158
|
+
$('#password-retry').addEventListener('click', open);
|
|
159
|
+
window.addEventListener('storage', event => {
|
|
160
|
+
if (event.key === DEVICE && event.newValue === null && current?.remember) {
|
|
161
|
+
forget(); disconnect(); feedback('此设备保存的登录已清除,请重新输入访问口令。');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return Object.freeze({ open, logout, forget, pause() { generation++; controller?.abort(); busy = false; } });
|
|
165
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// CLI-only access to one explicitly selected workbench; no directory discovery or raw credential output.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createWorkbenchAuth, resolveWorkbenchAuthDirectory, readWorkbenchPrivateJson, workbenchRootKey, WorkbenchAuthError } from './workbench-auth.js';
|
|
5
|
+
|
|
6
|
+
export async function manageWorkbenchPassword({ dataRoot, directory: supplied, reset = false, signal }) {
|
|
7
|
+
const directory = resolveWorkbenchAuthDirectory(dataRoot, supplied), file = path.join(directory, 'active.json');
|
|
8
|
+
let active;
|
|
9
|
+
if (fs.existsSync(file)) {
|
|
10
|
+
active = readWorkbenchPrivateJson(file).value;
|
|
11
|
+
if (!active || Object.keys(active).sort().join(',') !== 'accessToken,authId,origin,pid,rootKey,schemaVersion,state'
|
|
12
|
+
|| active.schemaVersion !== 0 || active.rootKey !== workbenchRootKey(dataRoot) || !['open', 'closed'].includes(active.state)
|
|
13
|
+
|| !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(active.origin) || Number(active.origin.split(':').at(-1)) > 65535
|
|
14
|
+
|| !Number.isInteger(active.pid) || active.pid < 1 || !/^[a-f0-9]{32}$/.test(active.authId)
|
|
15
|
+
|| (active.state === 'open' ? !/^[A-Za-z0-9_-]{43}$/.test(active.accessToken) : active.accessToken !== null)) {
|
|
16
|
+
throw new WorkbenchAuthError('IRIS_WEB_AUTH_CONFIG_INVALID', '工作台控制记录无效,已保留现场', 409);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (active?.state === 'open') {
|
|
20
|
+
const request = async route => {
|
|
21
|
+
const response = await fetch(active.origin + '/api/v0/workbench/' + route, { method: 'POST',
|
|
22
|
+
headers: { Authorization: 'Bearer ' + active.accessToken, 'Content-Type': 'application/json' }, body: '{}', redirect: 'error',
|
|
23
|
+
signal: AbortSignal.any([AbortSignal.timeout(15000), ...(signal ? [signal] : [])]) });
|
|
24
|
+
const value = await response.json();
|
|
25
|
+
if (!response.ok) throw new WorkbenchAuthError(value.error?.code || 'IRIS_WEB_AUTH_UNAVAILABLE', value.error?.message || '工作台控制请求失败', response.status);
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
if (reset) {
|
|
29
|
+
const value = await request('password-reset');
|
|
30
|
+
return { contractVersion: 0, origin: active.origin, loginUrl: value.setupUrl, passwordReset: true,
|
|
31
|
+
message: '工作台会话已注销;打开设置链接重新设置访问口令。Core 任务与 MCP 连接保持。' };
|
|
32
|
+
}
|
|
33
|
+
const response = await fetch(active.origin + '/api/v0/workbench/auth', { redirect: 'error',
|
|
34
|
+
signal: AbortSignal.any([AbortSignal.timeout(15000), ...(signal ? [signal] : [])]) });
|
|
35
|
+
const status = await response.json();
|
|
36
|
+
if (!response.ok || status.authId !== active.authId || status.mode !== 'password') throw new WorkbenchAuthError('IRIS_WEB_AUTH_UNAVAILABLE', '工作台身份或认证模式已变化,请核对当前服务', 409);
|
|
37
|
+
return { contractVersion: 0, origin: active.origin, loginUrl: status.configured ? active.origin : (await request('setup-link')).setupUrl,
|
|
38
|
+
message: status.configured ? '打开工作台地址,用访问口令登录;记住设备后无需每次重新登录。' : '打开首次设置链接,设置一次访问口令。' };
|
|
39
|
+
}
|
|
40
|
+
if (!reset || !fs.existsSync(path.join(directory, 'state.json'))) {
|
|
41
|
+
throw new WorkbenchAuthError('IRIS_WEB_AUTH_UNAVAILABLE', '工作台未运行,请先执行 web serve;无需手工指定 Token 文件', 409);
|
|
42
|
+
}
|
|
43
|
+
const auth = createWorkbenchAuth({ directory, dataRoot });
|
|
44
|
+
try { auth.start(); await auth.reset(); }
|
|
45
|
+
finally { await auth.dispose(); }
|
|
46
|
+
return { contractVersion: 0, passwordReset: true, message: '访问口令已重置。下次 web serve 启动时打开首次设置入口;原任务和连接保持。' };
|
|
47
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Single-owner browser authentication. No business storage, provider credentials or MCP grants.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { createCoreRuntime } from './core-runtime.js';
|
|
8
|
+
import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
|
|
9
|
+
|
|
10
|
+
const scrypt = promisify(crypto.scrypt), TOKEN = /^[A-Za-z0-9_-]{43}$/, HEX = /^[a-f0-9]{64}$/;
|
|
11
|
+
const SESSION_MS = 12 * 60 * 60 * 1000, REMEMBER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
14
|
+
const fields = (value, names) => value && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
&& Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name));
|
|
16
|
+
const inside = (root, target) => { const rel = path.relative(root, target); return !rel || rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel); };
|
|
17
|
+
|
|
18
|
+
export class WorkbenchAuthError extends Error {
|
|
19
|
+
constructor(code, message, status = 400) { super(message); this.name = 'WorkbenchAuthError'; this.code = code; this.status = status; }
|
|
20
|
+
}
|
|
21
|
+
const fail = (code, message, status) => { throw new WorkbenchAuthError(code, message, status); };
|
|
22
|
+
export const workbenchRootKey = dataRoot => hash(fs.realpathSync.native(dataRoot));
|
|
23
|
+
|
|
24
|
+
export function resolveWorkbenchAuthDirectory(dataRoot, supplied) {
|
|
25
|
+
if (supplied !== undefined) {
|
|
26
|
+
if (typeof supplied !== 'string' || !path.isAbsolute(supplied)) fail('IRIS_WEB_AUTH_CONFIG_INVALID', 'auth-store 必须是私有目录的绝对路径');
|
|
27
|
+
return path.resolve(supplied);
|
|
28
|
+
}
|
|
29
|
+
const home = process.env.IRIS_HOME || path.join(os.homedir(), '.iris');
|
|
30
|
+
if (!path.isAbsolute(home)) fail('IRIS_WEB_AUTH_CONFIG_INVALID', 'IRIS_HOME 必须是绝对路径');
|
|
31
|
+
// Preserve v0 directory placement; record identity follows Core's native root.
|
|
32
|
+
return path.join(home, 'web-auth', hash(fs.realpathSync(dataRoot)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readWorkbenchPrivateJson(file) {
|
|
36
|
+
const info = fs.lstatSync(file);
|
|
37
|
+
if (!info.isFile() || info.isSymbolicLink() || process.platform !== 'win32' && info.mode & 0o077) {
|
|
38
|
+
fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证文件须为私有普通文件;不会调整已有文件权限', 409);
|
|
39
|
+
}
|
|
40
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
41
|
+
try {
|
|
42
|
+
const bytes = Buffer.alloc(65537); let offset = 0, count;
|
|
43
|
+
while (offset < bytes.length && (count = fs.readSync(fd, bytes, offset, bytes.length - offset, null))) offset += count;
|
|
44
|
+
if (offset > 65536) fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证文件超过大小限制', 409);
|
|
45
|
+
const text = bytes.subarray(0, offset).toString('utf8');
|
|
46
|
+
let value;
|
|
47
|
+
try { value = JSON.parse(text); } catch (_) { fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证文件不是有效 JSON,已保留现场', 409); }
|
|
48
|
+
return { value, digest: hash(text) };
|
|
49
|
+
} finally { fs.closeSync(fd); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function prepareDirectory(directory, dataRoot) {
|
|
53
|
+
const supplied = path.resolve(directory); let parent = supplied;
|
|
54
|
+
while (!fs.existsSync(parent)) parent = path.dirname(parent);
|
|
55
|
+
const physical = path.resolve(fs.realpathSync.native(parent), path.relative(parent, supplied));
|
|
56
|
+
if (inside(dataRoot, physical) || inside(physical, dataRoot)) fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证目录与 Core 数据根须相互独立');
|
|
57
|
+
if (!fs.existsSync(supplied)) fs.mkdirSync(supplied, { recursive: true, mode: 0o700 });
|
|
58
|
+
const info = fs.lstatSync(supplied);
|
|
59
|
+
if (!info.isDirectory() || info.isSymbolicLink() || process.platform !== 'win32' && info.mode & 0o077) {
|
|
60
|
+
fail('IRIS_WEB_AUTH_CONFIG_INVALID', 'auth-store 须为私有真实目录;不会调整已有目录权限', 409);
|
|
61
|
+
}
|
|
62
|
+
return fs.realpathSync.native(supplied);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkedState(value, rootKey) {
|
|
66
|
+
if (!fields(value, ['schemaVersion', 'rootKey', 'id', 'password', 'sessions']) || value.schemaVersion !== 0 || value.rootKey !== rootKey
|
|
67
|
+
|| !/^[a-f0-9]{32}$/.test(value.id) || !Array.isArray(value.sessions) || value.sessions.length > 32) {
|
|
68
|
+
fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证状态的版本、根身份或结构不符合约定,已保留现场', 409);
|
|
69
|
+
}
|
|
70
|
+
const password = value.password;
|
|
71
|
+
if (password !== null && (!fields(password, ['algorithm', 'salt', 'hash']) || password.algorithm !== 'scrypt-v1'
|
|
72
|
+
|| !/^[a-f0-9]{32}$/.test(password.salt) || !/^[a-f0-9]{128}$/.test(password.hash))) {
|
|
73
|
+
fail('IRIS_WEB_AUTH_CONFIG_INVALID', '认证口令记录无效,不能自动修复或重置', 409);
|
|
74
|
+
}
|
|
75
|
+
if (new Set(value.sessions.map(item => item?.hash)).size !== value.sessions.length || password === null && value.sessions.length
|
|
76
|
+
|| value.sessions.some(item => !fields(item, ['hash', 'createdAt', 'expiresAt', 'remember']) || !HEX.test(item.hash)
|
|
77
|
+
|| typeof item.remember !== 'boolean' || !Number.isSafeInteger(item.createdAt) || item.createdAt < 0
|
|
78
|
+
|| item.expiresAt !== item.createdAt + (item.remember ? REMEMBER_MS : SESSION_MS))) {
|
|
79
|
+
fail('IRIS_WEB_AUTH_CONFIG_INVALID', '浏览器会话记录无效,已保留现场', 409);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkedPassword(password) {
|
|
85
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 128) {
|
|
86
|
+
fail('IRIS_WEB_PASSWORD_INPUT_INVALID', '访问口令需为 8–128 个字符');
|
|
87
|
+
}
|
|
88
|
+
return password;
|
|
89
|
+
}
|
|
90
|
+
const derive = (password, salt) => scrypt(password, Buffer.from(salt, 'hex'), 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
91
|
+
|
|
92
|
+
/** Explicit owner lease and atomic state. Session authority survives normal service restarts. */
|
|
93
|
+
export function createWorkbenchAuth({ directory, dataRoot }) {
|
|
94
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory)) fail('IRIS_WEB_AUTH_CONFIG_INVALID', 'auth-store 必须是明确的私有绝对路径');
|
|
95
|
+
let phase = 'new', root, stateFile, controlFile, state, fingerprint, lease, bootstrap, control;
|
|
96
|
+
let pending, disposal, failures = 0, failedWindow = 0;
|
|
97
|
+
const rootKey = workbenchRootKey(dataRoot);
|
|
98
|
+
const open = () => { if (phase !== 'open') fail('IRIS_WEB_AUTH_UNAVAILABLE', '工作台认证尚未启动或正在关闭', 503); };
|
|
99
|
+
function persist(next) {
|
|
100
|
+
open();
|
|
101
|
+
if (readWorkbenchPrivateJson(stateFile).digest !== fingerprint) fail('IRIS_WEB_AUTH_CONFIG_CHANGED', '认证文件在服务外被修改,未覆盖;请在本机核对', 409);
|
|
102
|
+
const text = json(next); atomicWritePrivate(stateFile, text); state = next; fingerprint = hash(text);
|
|
103
|
+
}
|
|
104
|
+
function start() {
|
|
105
|
+
if (phase !== 'new') fail('IRIS_WEB_AUTH_UNAVAILABLE', '认证实例只能启动一次', 409);
|
|
106
|
+
root = prepareDirectory(directory, fs.realpathSync.native(dataRoot)); stateFile = path.join(root, 'state.json'); controlFile = path.join(root, 'active.json');
|
|
107
|
+
if (!fs.existsSync(stateFile)) {
|
|
108
|
+
if (fs.readdirSync(root).some(name => name !== 'ownership')) fail('IRIS_WEB_AUTH_CONFIG_INVALID', '非空目录不是工作台认证目录,未覆盖文件', 409);
|
|
109
|
+
try { atomicCreatePrivate(stateFile, json({ schemaVersion: 0, rootKey, id: crypto.randomBytes(16).toString('hex'), password: null, sessions: [] })); }
|
|
110
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
111
|
+
}
|
|
112
|
+
const loaded = readWorkbenchPrivateJson(stateFile); state = checkedState(loaded.value, rootKey); fingerprint = loaded.digest;
|
|
113
|
+
lease = createCoreRuntime({ dataRoot: path.join(root, 'ownership'), mode: 'writer' }); lease.start();
|
|
114
|
+
// A previous owner may finish a logout between validation and lease acquisition.
|
|
115
|
+
const owned = readWorkbenchPrivateJson(stateFile); state = checkedState(owned.value, rootKey); fingerprint = owned.digest;
|
|
116
|
+
phase = 'open'; bootstrap = state.password === null ? crypto.randomBytes(32).toString('base64url') : null;
|
|
117
|
+
return info();
|
|
118
|
+
}
|
|
119
|
+
function info() {
|
|
120
|
+
open(); return { mode: 'password', configured: state.password !== null, authId: state.id, rememberDays: 30, minPasswordLength: 8, maxPasswordLength: 128 };
|
|
121
|
+
}
|
|
122
|
+
function bindControl({ origin, accessToken }) {
|
|
123
|
+
open();
|
|
124
|
+
if (fs.existsSync(controlFile)) readWorkbenchPrivateJson(controlFile);
|
|
125
|
+
control = { schemaVersion: 0, rootKey, authId: state.id, state: 'open', pid: process.pid, origin, accessToken };
|
|
126
|
+
atomicWritePrivate(controlFile, json(control));
|
|
127
|
+
}
|
|
128
|
+
function setupLink(origin) {
|
|
129
|
+
open(); if (!bootstrap) fail('IRIS_WEB_PASSWORD_ALREADY_SET', '访问口令已设置,请在普通工作台地址登录', 409);
|
|
130
|
+
return origin + '/#setup=' + bootstrap;
|
|
131
|
+
}
|
|
132
|
+
function authenticate(token) {
|
|
133
|
+
open(); if (!TOKEN.test(token || '')) return false;
|
|
134
|
+
const item = state.sessions.find(item => item.hash === hash(token));
|
|
135
|
+
return !!item && item.expiresAt > Date.now();
|
|
136
|
+
}
|
|
137
|
+
function session(nextPassword, remember) {
|
|
138
|
+
open();
|
|
139
|
+
const alive = state.sessions.filter(item => item.expiresAt > Date.now());
|
|
140
|
+
if (alive.length >= 32) fail('IRIS_WEB_SESSION_LIMIT', '已达 32 个有效会话,请退出闲置会话,或在本机重置口令', 429);
|
|
141
|
+
const accessToken = crypto.randomBytes(32).toString('base64url'), createdAt = Date.now(), expiresAt = createdAt + (remember ? REMEMBER_MS : SESSION_MS);
|
|
142
|
+
persist({ ...state, password: nextPassword, sessions: [...alive, { hash: hash(accessToken), createdAt, expiresAt, remember }] });
|
|
143
|
+
return { authId: state.id, accessToken, expiresAt: new Date(expiresAt).toISOString(), remember };
|
|
144
|
+
}
|
|
145
|
+
function exclusive(callback) {
|
|
146
|
+
open(); if (pending) fail('IRIS_WEB_AUTH_BUSY', '正在处理另一项登录请求,请稍后重试', 429);
|
|
147
|
+
const job = Promise.resolve().then(callback); pending = job;
|
|
148
|
+
return job.finally(() => { pending = null; });
|
|
149
|
+
}
|
|
150
|
+
function setup({ ticket, password, remember }) {
|
|
151
|
+
checkedPassword(password);
|
|
152
|
+
if (typeof remember !== 'boolean') fail('IRIS_WEB_PASSWORD_INPUT_INVALID', '请选择是否记住此设备');
|
|
153
|
+
return exclusive(async () => {
|
|
154
|
+
if (!bootstrap || !TOKEN.test(ticket || '') || !crypto.timingSafeEqual(Buffer.from(hash(ticket), 'hex'), Buffer.from(hash(bootstrap), 'hex'))) {
|
|
155
|
+
fail('IRIS_WEB_SETUP_REQUIRED', '首次设置入口无效,请在本机运行 web login 获取当前设置入口', 401);
|
|
156
|
+
}
|
|
157
|
+
const salt = crypto.randomBytes(16).toString('hex'), derived = await derive(password, salt);
|
|
158
|
+
try {
|
|
159
|
+
const result = session({ algorithm: 'scrypt-v1', salt, hash: derived.toString('hex') }, remember);
|
|
160
|
+
bootstrap = null; return result;
|
|
161
|
+
} finally { derived.fill(0); }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function login({ password, remember }) {
|
|
165
|
+
checkedPassword(password);
|
|
166
|
+
if (typeof remember !== 'boolean') fail('IRIS_WEB_PASSWORD_INPUT_INVALID', '请选择是否记住此设备');
|
|
167
|
+
return exclusive(async () => {
|
|
168
|
+
if (!state.password) fail('IRIS_WEB_SETUP_REQUIRED', '请先在本机完成工作台访问口令设置', 409);
|
|
169
|
+
if (Date.now() - failedWindow >= 60000) { failures = 0; failedWindow = Date.now(); }
|
|
170
|
+
if (failures >= 5) fail('IRIS_WEB_PASSWORD_RATE_LIMITED', '口令尝试过于频繁,请一分钟后重试', 429);
|
|
171
|
+
const derived = await derive(password, state.password.salt);
|
|
172
|
+
try {
|
|
173
|
+
if (!crypto.timingSafeEqual(derived, Buffer.from(state.password.hash, 'hex'))) { failures++; fail('IRIS_WEB_PASSWORD_INVALID', '访问口令不正确', 401); }
|
|
174
|
+
} finally { derived.fill(0); }
|
|
175
|
+
failures = 0; return session(state.password, remember);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function logout(token) {
|
|
179
|
+
open(); if (!TOKEN.test(token || '')) fail('IRIS_WEB_PASSWORD_INPUT_INVALID', '浏览器会话格式无效');
|
|
180
|
+
const remaining = state.sessions.filter(item => item.hash !== hash(token));
|
|
181
|
+
if (remaining.length !== state.sessions.length) persist({ ...state, sessions: remaining });
|
|
182
|
+
return { signedOut: true };
|
|
183
|
+
}
|
|
184
|
+
function reset() {
|
|
185
|
+
return exclusive(() => { persist({ ...state, password: null, sessions: [] }); bootstrap = crypto.randomBytes(32).toString('base64url'); failures = 0; return { passwordReset: true }; });
|
|
186
|
+
}
|
|
187
|
+
function dispose() {
|
|
188
|
+
if (disposal) return disposal;
|
|
189
|
+
const wasOpen = phase === 'open'; phase = 'closing'; bootstrap = null;
|
|
190
|
+
disposal = (async () => {
|
|
191
|
+
if (pending) await Promise.allSettled([pending]);
|
|
192
|
+
const errors = [];
|
|
193
|
+
try { if (wasOpen && control) atomicWritePrivate(controlFile, json({ ...control, state: 'closed', accessToken: null })); } catch (error) { errors.push(error); }
|
|
194
|
+
try { await lease?.dispose(); } catch (error) { errors.push(error); }
|
|
195
|
+
phase = 'closed';
|
|
196
|
+
if (errors.length) throw new AggregateError(errors, '工作台认证释放失败');
|
|
197
|
+
})(); return disposal;
|
|
198
|
+
}
|
|
199
|
+
return Object.freeze({ start, info, bindControl, setupLink, authenticate, setup, login, logout, reset, dispose });
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mokuyoaxis/dsh-iris",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0-alpha.1",
|
|
4
|
+
"description": "A multimodal workspace for Agents: Core Web, MCP, CLI and native DSH tools with model pools, durable media tasks, ComfyUI and explicit plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
},
|
|
13
13
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
14
|
"./package.json": "./package.json",
|
|
15
|
-
"./doctor": "./lib/doctor.js"
|
|
15
|
+
"./doctor": "./lib/doctor.js",
|
|
16
|
+
"./provider-extensions": "./lib/provider-extensions.js",
|
|
17
|
+
"./plugins": {
|
|
18
|
+
"types": "./lib/plugins.d.ts",
|
|
19
|
+
"default": "./lib/plugins.js"
|
|
20
|
+
},
|
|
21
|
+
"./contact-sheet": {
|
|
22
|
+
"types": "./plugins/contact-sheet/index.d.mts",
|
|
23
|
+
"default": "./plugins/contact-sheet/index.mjs"
|
|
24
|
+
}
|
|
16
25
|
},
|
|
17
26
|
"files": [
|
|
27
|
+
"plugins/contact-sheet",
|
|
18
28
|
"lib",
|
|
19
29
|
"bin",
|
|
20
30
|
"cordis.patch.yml",
|
|
@@ -62,7 +72,10 @@
|
|
|
62
72
|
"vision",
|
|
63
73
|
"dashscope",
|
|
64
74
|
"prompt-optimization",
|
|
65
|
-
"media-workbench"
|
|
75
|
+
"media-workbench",
|
|
76
|
+
"mcp",
|
|
77
|
+
"comfyui",
|
|
78
|
+
"agent-tools"
|
|
66
79
|
],
|
|
67
80
|
"license": "MIT",
|
|
68
81
|
"engines": {
|
|
@@ -99,7 +112,9 @@
|
|
|
99
112
|
}
|
|
100
113
|
},
|
|
101
114
|
"dependencies": {
|
|
102
|
-
"
|
|
115
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
116
|
+
"sharp": "^0.35.5",
|
|
117
|
+
"zod": "4.6.7"
|
|
103
118
|
},
|
|
104
119
|
"publishConfig": {
|
|
105
120
|
"access": "public"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { definePlugin } from '@mokuyoaxis/dsh-iris/plugins';
|
|
3
|
+
|
|
4
|
+
/** Optional real business plugin. Uses public SDK and Sharp only; no filesystem or Core internals. */
|
|
5
|
+
export default definePlugin({
|
|
6
|
+
manifest: { contractVersion: 0, id: 'community.contact-sheet', version: '0.1.0', compatibility: { min: '0.3.0-alpha.1', maxExclusive: '0.4.0' }, contributions: {
|
|
7
|
+
operations: [{ id: 'create', version: '0.1.0', title: '多图对照板', mode: 'writer',
|
|
8
|
+
commands: ['artifact.read', 'artifact.import'], artifactInputs: ['artifactIds'],
|
|
9
|
+
inputSchema: { type: 'object', properties: {
|
|
10
|
+
artifactIds: { type: 'array', minItems: 1, maxItems: 16, items: { type: 'string', pattern: '^artifact_[a-f0-9]{24}$' } },
|
|
11
|
+
columns: { type: 'integer', minimum: 1, maximum: 4 }, cellSize: { type: 'integer', minimum: 128, maximum: 512 }
|
|
12
|
+
}, required: ['artifactIds', 'columns', 'cellSize'], additionalProperties: false }
|
|
13
|
+
}],
|
|
14
|
+
pages: [{ id: 'board', version: '0.1.0', title: '多图对照板', entry: 'web/board.mjs', styles: ['web/board.css'] }]
|
|
15
|
+
} },
|
|
16
|
+
activate() {
|
|
17
|
+
return { operations: { async create(input, { core, signal }) {
|
|
18
|
+
const cells = [], columns = Math.min(input.columns, input.artifactIds.length), gap = 12;
|
|
19
|
+
for (const [index, id] of input.artifactIds.entries()) {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const image = await core.execute('artifact.read', { artifact_id: id });
|
|
22
|
+
const bytes = await sharp(Buffer.from(image.imageBase64, 'base64'), { limitInputPixels: 40_000_000 })
|
|
23
|
+
.rotate().resize(input.cellSize, input.cellSize, { fit: 'contain', background: '#ffffff' }).png().toBuffer();
|
|
24
|
+
cells.push({ input: bytes, left: gap + index % columns * (input.cellSize + gap), top: gap + Math.floor(index / columns) * (input.cellSize + gap) });
|
|
25
|
+
}
|
|
26
|
+
const width = columns * (input.cellSize + gap) + gap, height = Math.ceil(cells.length / columns) * (input.cellSize + gap) + gap;
|
|
27
|
+
const image = await sharp({ create: { width, height, channels: 3, background: '#f4f2f8' } }).composite(cells).png().toBuffer();
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
const result = await core.execute('artifact.import', { image_bytes: image });
|
|
30
|
+
return { artifactId: result.artifact.id, mediaType: 'image/png', width, height, count: input.artifactIds.length };
|
|
31
|
+
} } };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.intro { margin-bottom:24px; }
|
|
2
|
+
.intro h2 { font-size:24px; letter-spacing:-.6px; }
|
|
3
|
+
.board-layout { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:20px; align-items:start; }
|
|
4
|
+
.image-choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; }
|
|
5
|
+
.image-choice { position:relative; margin:0; padding:8px; background:var(--soft); border:1px solid var(--line); border-radius:10px; cursor:pointer; }
|
|
6
|
+
.image-choice:has(input:checked) { border-color:var(--accent); background:var(--tint); }
|
|
7
|
+
.image-choice input { position:absolute; top:10px; left:10px; min-height:0; }
|
|
8
|
+
.image-choice img { display:block; width:100%; height:112px; object-fit:contain; }
|
|
9
|
+
.image-choice span { display:block; text-align:center; margin-top:6px; }
|
|
10
|
+
form button { display:block; width:100%; margin-top:12px; }
|
|
11
|
+
.board-result { margin-top:24px; border-top:1px solid var(--line); padding-top:20px; }
|
|
12
|
+
.board-result img { display:block; max-height:600px; margin:0 auto 16px; object-fit:contain; }
|
|
13
|
+
.board-result a { display:inline-block; padding:9px 14px; border:1px solid var(--line); border-radius:8px; text-decoration:none; }
|
|
14
|
+
@media(max-width:680px) { .board-layout { grid-template-columns:1fr; } form { order:-1; } }
|