@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.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.
Files changed (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
@@ -0,0 +1,122 @@
1
+ // Public CLI/HTTP acceptance. No private Core imports, Task writes or alternate queues.
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, chmod, readFile, writeFile, appendFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { spawn } from 'node:child_process';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { setup } from './setup.mjs';
10
+ import { coreCli, cleanEnvironment, runCli } from './core-cli.mjs';
11
+ import { createFixture } from './fixture.mjs';
12
+
13
+ export const credentialEnv = 'IRIS_B4_STARTER_FIXTURE_KEY';
14
+ export const syntheticKey = 'synthetic-b4-starter-key';
15
+ export async function createEnvironment({ proxyUrl } = {}) {
16
+ const work = await mkdtemp(path.join(os.tmpdir(), 'iris-provider-starter-')); await chmod(work, 0o700);
17
+ // Windows kill(SIGTERM) forces termination; exercise the public CLI's normal
18
+ // shutdown handler through the same isolated IPC preload used by Core tests.
19
+ const cooperativeSignals = process.platform === 'win32' || process.env.IRIS_TEST_CLI_SIGNAL_MODE === 'ipc';
20
+ const signalPreload = path.join(work, 'signal-preload.mjs');
21
+ if (cooperativeSignals) await writeFile(signalPreload, `process.on('message', message => {
22
+ if (message === 'iris-test-sigterm' && !process.emit('SIGTERM')) throw new Error('CLI SIGTERM handler is not installed');
23
+ });
24
+ process.channel.unref();
25
+ `, { mode: 0o600 });
26
+ const fixture = await createFixture();
27
+ const prepared = await setup({ directory: path.join(work, 'demo'), endpoint: fixture.origin, credentialEnv });
28
+ let networkConfigPath;
29
+ if (proxyUrl) {
30
+ const url = new URL(proxyUrl);
31
+ assert(url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port && !url.username && !url.password && !url.search && !url.hash && url.pathname === '/',
32
+ 'acceptance proxy must be an explicit credential-free loopback HTTP URL');
33
+ networkConfigPath = path.join(work, 'network.json');
34
+ const command = (...args) => runCli(['network', ...args, '--network-config', networkConfigPath]);
35
+ let value = await command('routing', 'init');
36
+ value = await command('nodes', 'add', '--input', JSON.stringify({ expectedRevision: value.revision,
37
+ node: { id: 'fixture-net', protocol: 'http', host: '127.0.0.1', port: Number(url.port) } }));
38
+ value = await command('pools', 'add', '--input', JSON.stringify({ expectedRevision: value.revision,
39
+ pool: { id: 'fixture-pool', mode: 'fixed', candidates: [{ kind: 'node', nodeId: 'fixture-net' }] } }));
40
+ const routed = path.join(prepared.directory, 'providers-routed.json');
41
+ await command('catalog', 'create', '--provider-config', prepared.providerConfigPath, '--output', routed);
42
+ await command('bindings', 'set', '--provider-config', routed, '--input', JSON.stringify({ expectedRevision: value.revision, providerId: 'wan-reference', poolId: 'fixture-pool' }));
43
+ prepared.providerConfigPath = routed;
44
+ }
45
+ const scheduler = path.join(work, 'scheduler.json');
46
+ const event = (phase, detail = {}) => appendFile(path.join(work, 'events.jsonl'), JSON.stringify({ phase, at: new Date().toISOString(), ...detail }) + '\n', { mode: 0o600 });
47
+ await writeFile(scheduler, JSON.stringify({ schemaVersion: 1, httpConcurrency: 2, requestsPerSecond: 100, remoteLimit: 4 }), { mode: 0o600 });
48
+ let child, exited, exit, started, sequence = 0, serviceStderr = () => '';
49
+ async function start({ present = true, version, key = syntheticKey } = {}) {
50
+ await event('starting', { present, ...(version ? { version } : {}) });
51
+ let entry = prepared.entry;
52
+ if (version) {
53
+ entry = path.join(work, 'version-' + version + '.mjs');
54
+ // Wrap this external template, never a private Iris implementation.
55
+ await writeFile(entry, `import plugin from ${JSON.stringify(pathToFileURL(prepared.entry).href)};\nexport default { ...plugin, manifest: { ...plugin.manifest, version: ${JSON.stringify(version)} } };\n`, { mode: 0o600 });
56
+ }
57
+ await writeFile(prepared.configPath, JSON.stringify({ schemaVersion: 0, entries: present ? [entry] : [] }) + '\n', { mode: 0o600 });
58
+ const tokenFile = path.join(work, 'owner-' + (++sequence)); let stdout = '', stderr = ''; exited = false;
59
+ serviceStderr = () => stderr;
60
+ child = spawn(process.execPath, [...(cooperativeSignals ? ['--import', pathToFileURL(signalPreload).href] : []), coreCli, 'web', 'serve', '--data-root', prepared.dataRoot,
61
+ '--plugin-config', prepared.configPath, '--provider-config', prepared.providerConfigPath, '--allow-execution', '--allow-provider-management',
62
+ '--connection-store', prepared.connectionStore, '--allow-connection-management', '--allow-mcp',
63
+ '--scheduler-config', scheduler, '--token-file', tokenFile, ...(networkConfigPath ? ['--network-config', networkConfigPath] : [])], {
64
+ stdio: ['ignore', 'pipe', 'pipe', ...(cooperativeSignals ? ['ipc'] : [])], env: { ...cleanEnvironment(), [credentialEnv]: key }
65
+ });
66
+ child.stdout.on('data', bytes => { stdout += bytes; }); child.stderr.on('data', bytes => { stderr += bytes; });
67
+ exit = new Promise(resolve => child.once('close', (code, signal) => { exited = true; resolve({ code, signal }); }));
68
+ const deadline = performance.now() + 15000;
69
+ while (!stdout.includes('\n') && !exited && performance.now() < deadline) await delay(10);
70
+ assert(stdout.includes('\n'), 'Core CLI startup failed: ' + stderr);
71
+ started = { ...JSON.parse(stdout.split('\n')[0]), accessToken: (await readFile(tokenFile, 'utf8')).trim() };
72
+ await event('started', { pid: child.pid });
73
+ child.once('close', () => { void writeFile(path.join(work, 'service-' + sequence + '.stderr.log'), stderr, { mode: 0o600 }); });
74
+ return started;
75
+ }
76
+ async function stop() {
77
+ if (child) await event('stopping', { pid: child.pid });
78
+ if (child && !exited) {
79
+ if (cooperativeSignals) child.send('iris-test-sigterm');
80
+ else child.kill('SIGTERM');
81
+ }
82
+ if (child) {
83
+ const result = await Promise.race([exit, delay(10000, undefined, { ref: false }).then(() => { throw new Error('Core CLI did not close within 10 seconds'); })]);
84
+ assert.equal(result.code, 143, JSON.stringify({ ...result, stderr: serviceStderr(), work }));
85
+ await event('stopped', result);
86
+ }
87
+ child = undefined;
88
+ }
89
+ async function raw(route, { value, key, headers, method = value === undefined ? 'GET' : 'POST' } = {}) {
90
+ // Each restarted service is a distinct acceptance instance; never reuse its predecessor's idle socket.
91
+ return fetch(started.origin + route, { method, headers: { Connection: 'close', Authorization: 'Bearer ' + started.accessToken,
92
+ ...(value === undefined ? {} : { 'Content-Type': 'application/json' }), ...(key ? { 'Idempotency-Key': key } : {}), ...headers },
93
+ ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(20000) });
94
+ }
95
+ async function http(route, options, expected = 200) {
96
+ const response = await raw(route, options), value = await response.json();
97
+ assert.equal(response.status, expected, JSON.stringify(value)); assert(!JSON.stringify(value).includes(syntheticKey)); return value;
98
+ }
99
+ async function terminal(id) {
100
+ for (let i = 0; i < 500; i++) {
101
+ const operation = (await http('/api/v0/operations/' + id)).operation;
102
+ if (!['queued', 'running'].includes(operation.state)) return operation;
103
+ await delay(10);
104
+ }
105
+ assert.fail('Operation did not finish');
106
+ }
107
+ const base = '/api/v0/connection-management/connections';
108
+ const listConnections = async () => (await http(base)).connections;
109
+ async function connection(capabilities = ['image-generate']) {
110
+ const value = await http(base, { value: { key: crypto.randomUUID(), label: 'Provider starter acceptance',
111
+ scopes: ['read', 'create', 'execute', 'stop'], capabilities } }, 201);
112
+ return (await listConnections()).find(record => record.connectionId === value.connectionId);
113
+ }
114
+ const exported = async record => (await http(base + '/' + record.connectionId + '/export', { value: { transport: 'http', format: 'entry' } })).configuration;
115
+ async function reduce(record) {
116
+ const current = (await listConnections()).find(item => item.connectionId === record.connectionId);
117
+ return http(base + '/' + record.connectionId + '/update', { value: { expectedRevision: current.revision, label: current.label,
118
+ scopes: ['read'], capabilities: current.capabilities, tools: current.tools, pluginOperations: current.pluginOperations } });
119
+ }
120
+ return { work, prepared, fixture, start, stop, raw, http, terminal, connection, exported, reduce,
121
+ get started() { return started; }, async close() { try { await stop(); } finally { await fixture.close(); } } };
122
+ }
@@ -0,0 +1,54 @@
1
+ // Local-only acceptance service. Never a real DashScope gateway.
2
+ import http from 'node:http';
3
+ import { once } from 'node:events';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ export const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
7
+ export async function createFixture() {
8
+ const requests = [], errors = [], tasks = new Set(); let ready = false, mode = 'normal', active = 0, peak = 0, origin, cdnOrigin, submitGate, release;
9
+ const sockets = new Set();
10
+ const track = server => server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); });
11
+ const cdn = http.createServer((req, res) => {
12
+ requests.push({ method: req.method, path: req.url, authorization: req.headers.authorization, cdn: true });
13
+ if (req.url === '/redirect') { res.writeHead(302, { Location: cdnOrigin + '/image.png' }); res.end(); }
14
+ else { res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(PNG); }
15
+ }); track(cdn); cdn.listen(0, '127.0.0.1'); await once(cdn, 'listening'); cdnOrigin = `http://127.0.0.1:${cdn.address().port}`;
16
+ async function handle(req, res) {
17
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
18
+ const row = { method: req.method, path: req.url, authorization: req.headers.authorization,
19
+ async: req.headers['x-dashscope-async'], body: Buffer.concat(chunks).toString('utf8'), cdn: false };
20
+ requests.push(row); res.setHeader('Content-Type', 'application/json');
21
+ if (mode === 'redirect') { res.writeHead(302, { Location: cdnOrigin + '/image.png' }); res.end(); return; }
22
+ if (mode === 'rate-limit') { res.writeHead(429, { 'Retry-After': '2' }); res.end('{"error":"synthetic-private-upstream-detail"}'); return; }
23
+ if (mode === 'html') { res.end('<html>synthetic-private-upstream-detail</html>'); return; }
24
+ if (mode === 'large') { res.end('x'.repeat(1024 * 1024 + 1)); return; }
25
+ const url = new URL(req.url, origin);
26
+ if (url.pathname === '/api/v1/models') {
27
+ const page = Number(url.searchParams.get('page_no'));
28
+ const models = page === 1 ? Array.from({ length: 100 }, (_, i) => ({ model: 'unsupported-fixture-' + i })) : [{ model: 'wan2.6-t2i' }];
29
+ res.end(JSON.stringify({ output: { total: mode === 'bad-page' && page === 2 ? 102 : 101,
30
+ page_no: page, page_size: 100, models } })); return;
31
+ }
32
+ if (url.pathname === '/api/v1/services/aigc/image-generation/generation' && req.method === 'POST') {
33
+ active++; peak = Math.max(peak, active); if (submitGate) await submitGate; else await delay(120); active--;
34
+ if (mode === 'reset') { req.socket.destroy(); return; }
35
+ if (mode === 'missing-id') { res.end('{"output":{}}'); return; }
36
+ const id = 'original-remote-' + (tasks.size + 1); tasks.add(id);
37
+ res.end(JSON.stringify({ output: { task_id: id, task_status: 'PENDING' } })); return;
38
+ }
39
+ if (url.pathname.startsWith('/api/v1/tasks/')) {
40
+ const id = decodeURIComponent(url.pathname.slice('/api/v1/tasks/'.length));
41
+ if (!tasks.has(id)) { res.writeHead(404).end('{}'); return; }
42
+ res.end(JSON.stringify({ output: { task_id: id, task_status: ready ? 'SUCCEEDED' : 'RUNNING',
43
+ ...(ready ? { choices: mode === 'bad-poll' ? {} : [{ message: { content: [{ image: cdnOrigin + '/redirect' }] } }] } : {}) } })); return;
44
+ }
45
+ res.writeHead(404).end('{}');
46
+ }
47
+ const server = http.createServer((req, res) => { void handle(req, res).catch(error => { errors.push(error.message); res.destroy(); }); });
48
+ track(server); server.listen(0, '127.0.0.1'); await once(server, 'listening'); origin = `http://127.0.0.1:${server.address().port}`;
49
+ return { origin, cdnOrigin, requests, errors, tasks, get peak() { return peak; },
50
+ set mode(value) { mode = value; }, set ready(value) { ready = value; },
51
+ holdSubmits() { submitGate = new Promise(resolve => { release = resolve; }); },
52
+ releaseSubmits() { release?.(); submitGate = undefined; },
53
+ async close() { release?.(); for (const socket of sockets) socket.destroy(); await Promise.all([server, cdn].map(item => new Promise(resolve => item.close(resolve)))); } };
54
+ }
@@ -0,0 +1,21 @@
1
+ // Acceptance client only. Harnesses import Core's exported configuration; the server uses its official MCP SDK.
2
+ export async function mcpProbe(configuration) {
3
+ let sequence = 0;
4
+ async function request(method, params, notification = false) {
5
+ const id = notification ? undefined : ++sequence;
6
+ const response = await fetch(configuration.url, { method: 'POST', headers: {
7
+ ...configuration.headers, Connection: 'close', 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
8
+ 'MCP-Protocol-Version': '2025-11-25'
9
+ }, body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }), signal: AbortSignal.timeout(20_000) });
10
+ const text = await response.text();
11
+ if (notification && response.status === 202) return;
12
+ const messages = response.headers.get('content-type')?.startsWith('text/event-stream')
13
+ ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5))) : [JSON.parse(text)];
14
+ const message = messages.find(item => item.id === id) || messages.at(-1);
15
+ if (!response.ok || message.error) throw new Error(message.error?.message || 'MCP HTTP ' + response.status);
16
+ return message.result;
17
+ }
18
+ await request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'iris-provider-starter-test', version: '0.1.0' } });
19
+ await request('notifications/initialized', {}, true);
20
+ return { request, call: (name, args) => request('tools/call', { name, arguments: args }) };
21
+ }
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createCorePluginRegistry, ProviderContractError } from '@mokuyoaxis/dsh-iris/plugins';
6
+ import plugin, { PROVIDER_PROTOCOL, SUPPORTED_MODEL } from '../index.mjs';
7
+ import { createFixture, PNG } from './fixture.mjs';
8
+
9
+ const fixture = await createFixture(), registry = await createCorePluginRegistry({ plugins: [plugin] });
10
+ const work = await mkdtemp(path.join(os.tmpdir(), 'iris-wan-protocol-'));
11
+ const adapter = registry.providers.createAdapter(PROVIDER_PROTOCOL, { id: 'p', baseUrl: fixture.origin, apiKey: 'synthetic-private-upstream-detail' });
12
+ const methods = adapter.operations, request = { capability: 'image', model: SUPPORTED_MODEL, input: { prompt: 'local fixture', n: 1 } };
13
+ async function rejects(action, stage, acceptance, category = 'protocol') {
14
+ await assert.rejects(action, error => error instanceof ProviderContractError && error.stage === stage && error.acceptance === acceptance && error.category === category
15
+ && !error.message.includes('synthetic-private-upstream-detail'));
16
+ }
17
+ try {
18
+ assert.deepEqual((await methods.discover({})).models, [{ id: SUPPORTED_MODEL, capabilities: ['image-gen'] }]);
19
+ fixture.mode = 'bad-page'; await rejects(() => methods.discover({}), 'validate', 'not_accepted');
20
+ fixture.mode = 'normal'; const before = fixture.requests.length;
21
+ await rejects(() => methods.submit({ ...request, model: 'unsupported' }), 'validate', 'not_accepted', 'invalid_request');
22
+ await rejects(() => methods.submit({ ...request, input: { prompt: 'x', n: 5 } }), 'validate', 'not_accepted', 'invalid_request');
23
+ assert.equal(fixture.requests.length, before);
24
+ const accepted = await methods.submit(request), remote = { capability: 'image', remoteTaskId: accepted.remoteTaskId };
25
+ assert.equal((await methods.poll(remote)).kind, 'pending'); fixture.ready = true;
26
+ fixture.mode = 'bad-poll'; await rejects(() => methods.poll(remote), 'poll', 'accepted'); fixture.mode = 'normal';
27
+ const result = await methods.poll(remote), targetPath = path.join(work, 'image.png');
28
+ assert.equal((await methods.download({ capability: 'image', artifact: result.artifacts[0], targetPath })).bytes, PNG.length);
29
+ assert.deepEqual(await readFile(targetPath), PNG);
30
+ assert(fixture.requests.filter(row => row.cdn).every(row => row.authorization === undefined));
31
+ await assert.rejects(methods.download({ capability: 'image', artifact: result.artifacts[0], targetPath }), { code: 'EEXIST' });
32
+ for (const mode of ['missing-id', 'html', 'large']) {
33
+ fixture.mode = mode; await rejects(() => methods.submit(request), 'response', 'unknown');
34
+ }
35
+ fixture.mode = 'rate-limit';
36
+ await assert.rejects(methods.submit(request), error => error.acceptance === 'not_accepted' && error.category === 'rate_limit' && error.retryAfterMs === 2000);
37
+ fixture.mode = 'redirect'; const cdnBefore = fixture.requests.filter(row => row.cdn).length;
38
+ await assert.rejects(methods.submit(request), error => {
39
+ const mapped = methods.mapError(error, { stage: 'submit', acceptance: 'unknown' });
40
+ return mapped.acceptance === 'unknown' && !mapped.message.includes('synthetic-private-upstream-detail');
41
+ });
42
+ assert.equal(fixture.requests.filter(row => row.cdn).length, cdnBefore, 'authenticated API redirects are rejected');
43
+ fixture.mode = 'normal'; const controller = new AbortController(); controller.abort();
44
+ await assert.rejects(methods.discover({ signal: controller.signal }), error => methods.mapError(error, { stage: 'validate', acceptance: 'not_accepted' }).category === 'aborted');
45
+ assert.deepEqual(fixture.errors, []);
46
+ console.log(JSON.stringify({ ok: true, work, checks: ['complete directory and invalid pagination', 'explicit model/n validation before HTTP', 'accepted original task and pending/succeeded poll',
47
+ 'bounded Core-target delivery, no overwrite and credential-free CDN', 'unknown acceptance on malformed/oversized response', '429 cooldown hint', 'no authenticated redirect or raw error echo', 'caller cancellation'] }, null, 2));
48
+ } finally { await registry.dispose(); await fixture.close(); }
@@ -0,0 +1,44 @@
1
+ import { mkdir, writeFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import plugin, { PROVIDER_PROTOCOL } from '../index.mjs';
5
+ import { coreCli, runCore } from './core-cli.mjs';
6
+
7
+ export async function setup({ directory, endpoint, credentialEnv }) {
8
+ if (!path.isAbsolute(directory)) throw new Error('--directory must be an absolute path to a new directory.');
9
+ if (typeof credentialEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(credentialEnv)) throw new Error('--credential-env must be an environment variable name, never a key value.');
10
+ const url = new URL(endpoint);
11
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
12
+ || /\/(?:api\/v1|compatible-mode\/v1)\/?$/.test(url.pathname)) throw new Error('--endpoint must be the explicit workspace API root, without credentials, query or /api/v1.');
13
+ // Resolve the existing parent so a symlink cannot put private data in the distributed project.
14
+ const projectRoot = await realpath(fileURLToPath(new URL('..', import.meta.url)));
15
+ const target = path.join(await realpath(path.dirname(directory)), path.basename(directory));
16
+ const relative = path.relative(projectRoot, target);
17
+ if (relative === '' || relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) throw new Error('Demo data must be outside the plugin project.');
18
+ // Reject all existing directories, including empty ones; leave partial output for diagnosis on failure.
19
+ await mkdir(target, { mode: 0o700 });
20
+ const dataRoot = path.join(target, 'core'), providerConfigPath = path.join(target, 'providers.json'),
21
+ configPath = path.join(target, 'plugins.json'), connectionStore = path.join(target, 'connections');
22
+ const entry = fileURLToPath(new URL('../index.mjs', import.meta.url));
23
+ await writeFile(configPath, JSON.stringify({ schemaVersion: 0, entries: [entry] }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
24
+ await writeFile(providerConfigPath, JSON.stringify({ providers: [{ id: 'wan-reference', name: 'Wan reference',
25
+ enabled: true, baseUrl: endpoint.replace(/\/+$/, ''), apiKeyEnv: credentialEnv, mediaProtocol: PROVIDER_PROTOCOL, models: [] }],
26
+ assignments: { 'image-gen': { mode: 'disabled', models: [] } } }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
27
+ await runCore(dataRoot, 'flow', 'init');
28
+ await runCore(dataRoot, 'execution', 'init', '--image-generation');
29
+ return { directory: target, dataRoot, configPath, providerConfigPath, connectionStore, entry,
30
+ pluginId: plugin.manifest.id,
31
+ startArgv: [process.execPath, coreCli, 'web', 'serve', '--data-root', dataRoot, '--plugin-config', configPath,
32
+ '--provider-config', providerConfigPath, '--allow-provider-management', '--allow-execution',
33
+ '--connection-store', connectionStore, '--allow-connection-management', '--allow-mcp', '--port', '3182'] };
34
+ }
35
+
36
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
37
+ const args = process.argv.slice(2), allowed = ['--directory', '--endpoint', '--credential-env'];
38
+ if (args.length !== 6 || allowed.some(name => args.filter(item => item === name).length !== 1)
39
+ || args.some((value, index) => index % 2 === 0 && !allowed.includes(value))) {
40
+ throw new Error('Usage: npm run setup -- --directory /absolute/new-private-directory --endpoint https://YOUR-WORKSPACE-API-ROOT --credential-env YOUR_KEY_VARIABLE');
41
+ }
42
+ const options = Object.fromEntries(args.reduce((pairs, value, index) => index % 2 ? [...pairs, [args[index - 1].slice(2), value]] : pairs, []));
43
+ console.log(JSON.stringify(await setup({ directory: options.directory, endpoint: options.endpoint, credentialEnv: options['credential-env'] }), null, 2));
44
+ }
@@ -0,0 +1,145 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { createEnvironment, credentialEnv, syntheticKey } from './environment.mjs';
6
+ import { setup } from './setup.mjs';
7
+ import { runCore } from './core-cli.mjs';
8
+ import { mcpProbe } from './mcp-probe.mjs';
9
+ import { PNG } from './fixture.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+ assert(args.length === 0 || args.length === 2 && args[0] === '--proxy-url', 'Usage: node scripts/smoke.mjs [--proxy-url http://127.0.0.1:PORT]');
13
+ const env = await createEnvironment({ proxyUrl: args[1] }), checks = [];
14
+ const parsed = result => { assert(!result.isError, JSON.stringify(result)); return result.structuredContent ?? JSON.parse(result.content.find(item => item.type === 'text').text); };
15
+ const postCount = () => env.fixture.requests.filter(item => item.method === 'POST').length;
16
+ const prefix = '/api/v0/provider-management/';
17
+ async function continuation(runId) {
18
+ const view = await env.http('/api/v0/runs/' + runId);
19
+ const accepted = await env.http('/api/v0/runs/' + runId + '/continue', { key: crypto.randomUUID(), value: { expectedRevision: view.revision } }, 202);
20
+ return env.terminal(accepted.operation.id);
21
+ }
22
+ async function noCredentialFiles(directory) {
23
+ for (const item of await readdir(directory, { withFileTypes: true })) {
24
+ const file = path.join(directory, item.name);
25
+ if (item.isDirectory()) await noCredentialFiles(file);
26
+ else { const bytes = await readFile(file); assert(!bytes.includes(Buffer.from(syntheticKey)), 'synthetic provider credential persisted: ' + item.name); }
27
+ }
28
+ }
29
+ try {
30
+ const original = await readFile(env.prepared.providerConfigPath);
31
+ const options = { directory: env.prepared.directory, endpoint: env.fixture.origin, credentialEnv };
32
+ await assert.rejects(setup(options), { code: 'EEXIST' });
33
+ await assert.rejects(setup({ ...options, directory: path.resolve('private-demo') }), /outside the plugin project/);
34
+ assert.deepEqual(await readFile(env.prepared.providerConfigPath), original);
35
+ const originalConfig = JSON.parse(original).catalog ?? JSON.parse(original);
36
+ assert.equal(originalConfig.providers[0].apiKey, undefined);
37
+ assert.deepEqual(originalConfig.providers[0].models, []);
38
+ checks.push('new-directory setup rejects overwrite and package-local data; stores only the environment name with no enabled model pool');
39
+
40
+ await env.start({ key: '' });
41
+ let settings = await env.http(prefix + 'settings');
42
+ await env.http(prefix + 'discover', { value: { providerId: 'wan-reference', expectedRevision: settings.revision } }, 409);
43
+ assert.equal(env.fixture.requests.length, 0);
44
+ await env.stop(); await env.start();
45
+ settings = await env.http(prefix + 'settings');
46
+ assert(settings.protocols.some(item => item.id === 'example.wan-image.images'));
47
+ const discovered = await env.http(prefix + 'discover', { value: { providerId: 'wan-reference', expectedRevision: settings.revision } });
48
+ assert.equal(discovered.persisted, false); assert.deepEqual(discovered.models.map(item => item.id), ['wan2.6-t2i']);
49
+ assert.equal(env.fixture.requests.filter(item => item.path.startsWith('/api/v1/models')).length, 2);
50
+ assert.deepEqual(await readFile(env.prepared.providerConfigPath), original);
51
+ checks.push('actual Core CLI exposes the contributed protocol; missing credentials make zero requests; complete two-page directory is read without silently applying it');
52
+
53
+ const record = settings.providers[0];
54
+ const provider = Object.fromEntries(['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth'].map(field => [field, record[field]]));
55
+ provider.models = discovered.models;
56
+ settings = await env.http(prefix + 'save', { value: { expectedRevision: settings.revision, provider, credential: { action: 'keep' },
57
+ assignments: { 'image-gen': { mode: 'manual', models: ['wan-reference::wan2.6-t2i'] } } } });
58
+ const none = await env.connection([]), unselected = await mcpProbe(await env.exported(none));
59
+ assert(!(await unselected.request('tools/list', {})).tools.some(item => item.name === 'iris_core_create_generation'));
60
+ const connection = await env.connection(); let client = await mcpProbe(await env.exported(connection));
61
+ let names = (await client.request('tools/list', {})).tools.map(item => item.name);
62
+ assert(names.includes('iris_core_create_generation')); assert(!names.includes('iris_core_plugin_call')); assert(!names.includes('iris_core_create_video'));
63
+ checks.push('user explicitly applies the discovered model and manual pool; selecting image generation controls ordinary MCP media tools without a separate Provider tool whitelist');
64
+
65
+ const flows = [];
66
+ for (let i = 0; i < 2; i++) flows.push(parsed(await client.call('iris_core_create_generation', {
67
+ key: crypto.randomUUID(), modelRef: 'wan-reference::wan2.6-t2i', prompt: 'controlled local Provider starter ' + i
68
+ })).flow);
69
+ assert.equal(postCount(), 0);
70
+ const requests = flows.map(flow => ({ key: crypto.randomUUID(), runId: flow.id, expectedRevision: flow.revision }));
71
+ env.fixture.holdSubmits();
72
+ const accepted = await Promise.all(requests.map(value => client.call('iris_core_continue', value).then(parsed)));
73
+ const deadline = performance.now() + 5000;
74
+ while (postCount() < 2 && performance.now() < deadline) await delay(10);
75
+ env.fixture.releaseSubmits();
76
+ for (const value of accepted) assert.equal((await env.terminal(value.operation.id)).state, 'succeeded');
77
+ assert.equal(postCount(), 2); assert.equal(env.fixture.peak, 2);
78
+ const submitted = env.fixture.requests.filter(item => item.method === 'POST');
79
+ assert(submitted.every(item => item.async === 'enable' && JSON.parse(item.body).parameters.n === 1));
80
+ await env.stop();
81
+ const tasks = [];
82
+ for (const flow of flows) {
83
+ const view = await runCore(env.prepared.dataRoot, 'flow', 'inspect', flow.id);
84
+ const task = (await runCore(env.prepared.dataRoot, 'task', 'inspect', view.task.id)).task;
85
+ assert.equal(task.acceptance, 'accepted'); assert(env.fixture.tasks.has(task.remoteTaskId));
86
+ assert.equal(task.pluginDependency.pluginVersion, '0.1.0'); assert.equal(task.pluginDependency.contributionId, 'example.wan-image.images');
87
+ assert.deepEqual(task.pluginDependency, task.attempts[0].pluginDependency); tasks.push(task.id);
88
+ if (args.length) { assert.equal(task.semanticsVersion, 3); assert(task.networkRoutes.some(route => route.operation === 'submit' && route.nodeId === 'fixture-net')); }
89
+ }
90
+ checks.push('two real local HTTP submissions overlap under Core scheduling; public CLI reads accepted Task/Attempt with exact plugin identity and original remote IDs');
91
+
92
+ for (const selection of [{ present: false }, { version: '0.1.1' }]) {
93
+ await env.start(selection); const before = env.fixture.requests.length;
94
+ for (const flow of flows) {
95
+ const view = await env.http('/api/v0/runs/' + flow.id); assert(tasks.includes(view.task.id));
96
+ const result = await continuation(flow.id); assert.equal(result.state, 'failed', JSON.stringify(result));
97
+ }
98
+ assert.equal(env.fixture.requests.length, before, 'missing/mismatched plugin must not contact any upstream');
99
+ await env.stop();
100
+ }
101
+ checks.push('absent or differently versioned plugin keeps saved history readable and blocks continuation before any upstream request');
102
+
103
+ env.fixture.ready = true;
104
+ const rotated = syntheticKey + '-rotated'; await env.start({ key: rotated }); client = await mcpProbe(await env.exported(connection));
105
+ for (let i = 0; i < flows.length; i++) {
106
+ const view = parsed(await client.call('iris_core_run', { runId: flows[i].id }));
107
+ const operation = parsed(await client.call('iris_core_continue', { key: crypto.randomUUID(), runId: view.id, expectedRevision: view.revision })).operation;
108
+ assert.equal((await env.terminal(operation.id)).state, 'succeeded');
109
+ const delivered = parsed(await client.call('iris_core_run', { runId: view.id }));
110
+ assert.equal(delivered.task.id, tasks[i]); assert.equal(delivered.task.attemptCount, 1); assert.equal(delivered.task.deliveryState, 'ready');
111
+ const image = (await client.call('iris_core_image', { artifactId: delivered.output.artifactId })).content.find(item => item.type === 'image');
112
+ assert.deepEqual(Buffer.from(image.data, 'base64'), PNG);
113
+ }
114
+ assert.equal(postCount(), 2);
115
+ const polls = env.fixture.requests.filter(item => item.path.startsWith('/api/v1/tasks/'));
116
+ assert(polls.length >= 2 && polls.every(item => item.authorization === 'Bearer ' + rotated));
117
+ assert(env.fixture.requests.filter(item => item.cdn).every(item => item.authorization === undefined));
118
+ checks.push('restoring the exact plugin after restart uses the rotated environment value to poll original remote tasks and follow credential-free CDN redirects; no repeated POST');
119
+
120
+ env.fixture.mode = 'missing-id';
121
+ const ambiguous = (await env.http('/api/v0/generations', { key: crypto.randomUUID(), value: {
122
+ modelRef: 'wan-reference::wan2.6-t2i', prompt: 'controlled ambiguous response'
123
+ } }, 201)).flow;
124
+ await continuation(ambiguous.id);
125
+ let ambiguousView = await env.http('/api/v0/runs/' + ambiguous.id);
126
+ assert.equal(ambiguousView.task.acceptance, 'unknown'); assert.equal(ambiguousView.task.attemptCount, 1);
127
+ const ambiguousTask = ambiguousView.task.id, count = postCount(); assert.equal(count, 3);
128
+ await env.stop(); env.fixture.mode = 'normal'; await env.start({ key: rotated });
129
+ const requestCount = env.fixture.requests.length; await continuation(ambiguous.id);
130
+ ambiguousView = await env.http('/api/v0/runs/' + ambiguous.id);
131
+ assert.equal(ambiguousView.task.id, ambiguousTask); assert.equal(ambiguousView.task.acceptance, 'unknown');
132
+ assert.equal(env.fixture.requests.length, requestCount); assert.equal(postCount(), count);
133
+ checks.push('a submission without remote identity persists unknown acceptance; restart and explicit continuation neither retry POST nor invent a poll ID');
134
+
135
+ await env.reduce(connection); client = await mcpProbe(await env.exported(connection));
136
+ names = (await client.request('tools/list', {})).tools.map(item => item.name);
137
+ assert(!names.includes('iris_core_continue')); assert(!names.includes('iris_core_create_generation'));
138
+ assert.equal(parsed(await client.call('iris_core_run', { runId: flows[0].id })).task.deliveryState, 'ready');
139
+ await assert.rejects(client.call('iris_core_continue', requests[0]), /Tool iris_core_continue not found/);
140
+ assert.equal(postCount(), count); checks.push('reducing connection authority removes and rejects new execution while retaining authorized original history');
141
+ await env.stop(); await noCredentialFiles(env.prepared.directory); assert.deepEqual(env.fixture.errors, []);
142
+ checks.push('normal CLI SIGTERM completes across restarts; provider credentials are absent from Core records, provider configuration and backups');
143
+ console.log(JSON.stringify({ ok: true, work: env.work, checks, taskIds: tasks, paidCalls: 0, submits: postCount(), peakHttp: env.fixture.peak,
144
+ proxy: args.length ? 'explicit-loopback-CONNECT' : 'inherit' }, null, 2));
145
+ } finally { await env.close(); }
package/user_guide.md CHANGED
@@ -64,6 +64,10 @@ DSH 已配好同一媒体服务时,可在第 1 步选择连接来源,明确
64
64
 
65
65
  保存供应商和修改能力分配都会立即生效,不需要重启 DSH。
66
66
 
67
+ ## ComfyUI 工作台(0.3 beta)
68
+
69
+ 稳定npm 0.2.7尚不含此界面;安装0.3.0-beta.1后可在“+ 添加供应商”选择“ComfyUI · 已有服务”,保存地址/认证后导入 API 图或 Iris 参数模板,明确节点和公开参数;检查、保存不生成图片。画图时选择具体工作流,可调整 LoRA 双权重、种子等声明参数,并从只读检查取得可用选项。Iris 不安装 ComfyUI 或模型,静态检查不等于真实 GPU 验收。完整操作与限制见 [DSH ComfyUI 入口](docs/IMAGE_GENERATION.md#dsh-工作台入口03-alpha)。
70
+
67
71
  ## 供应商与协议
68
72
 
69
73
  Iris 当前支持两类调用路径:
@@ -220,7 +224,7 @@ $DSH_HOME/iris/v1/providers.json
220
224
 
221
225
  `mediaBaseUrl` 可以省略或留空,此时媒体调用沿用 `baseUrl`。`assignments` 可以省略,但从 0.2.4 起缺失分配不发起对应功能的新调用;0.2.6 也不会将其静默改为自动池。包含特殊字符的 provider 或模型引用会经过 URL 编码,复杂引用建议在工作台中生成,不要手写。
222
226
 
223
- 可为明确不需要认证的本地端点设置 `"auth": "none"` 并省略 `apiKey`;缺省或 `"auth": "bearer"` 仍要求 Key,保持旧配置行为。无认证模式不会发送 Authorization 头,已有的旧 Key 也不会被带入该模式的媒体请求。工作台尚无专门的无认证开关,需要按上述停机原则编辑私有配置;不要对需要认证的公网端点使用 `auth:none`。
227
+ 可为明确不需要认证的本地端点设置 `"auth": "none"` 并省略 `apiKey`;缺省或 `"auth": "bearer"` 仍要求 Key,保持旧配置行为。无认证模式不会发送 Authorization 头,已有的旧 Key 也不会被带入该模式的媒体请求。0.2.7 工作台尚无专门的无认证开关,需按上述停机原则编辑私有配置;beta.1已在添加供应商与认证设置中提供选择。不要对需要认证的公网端点使用 `auth:none`。
224
228
 
225
229
  API Key 以明文保存在宿主侧的 `providers.json` 中。POSIX 上 Iris 目录为 `0700`、文件为 `0600`,0.1.1 首次启动会收紧既有 Iris 树但不修改内容或跟随符号链接;Windows 的 mode 不能替代 ACL。状态接口和界面只返回掩码,不会显示完整 Key。不要把这个文件提交到版本库或发给他人。
226
230