@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 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 (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -0,0 +1,50 @@
1
+ # Wan Provider starter / 万相供应商模板
2
+
3
+ A copyable, independent Provider plugin using only `@mokuyoaxis/dsh-iris/plugins` and the public `package.json` export to locate the CLI. It implements the Wan 2.6 asynchronous image protocol itself. It does not call Iris's private transports or duplicate its Task store, queue, authentication or MCP server.
4
+
5
+ This template requires **Core 0.3.0-beta.1 and Provider factory context v1**. It checks `PROVIDER_FACTORY_CONTEXT_VERSION === 1` before setup. Install the exact beta package or a verified compatible Core archive. The older alpha.1 package fails with an explicit feature error and creates no data.
6
+
7
+ ## Copy and check
8
+
9
+ Copy this entire directory outside the Iris repository into a new project, then install the explicitly chosen Core archive in that project:
10
+
11
+ ```bash
12
+ npm install --save-exact @mokuyoaxis/dsh-iris@0.3.0-beta.1
13
+ node scripts/protocol-smoke.mjs
14
+ npm test
15
+ ```
16
+
17
+ These tests use synthetic credentials and loopback HTTP services only. They initialize new private temporary roots, run the actual installed Core CLI and formal HTTP MCP, and preserve their evidence directories. No provider account, paid call or harness configuration is involved. Archive acceptance separately checks the same template as its own npm tarball. The template has no runtime dependency beyond its Core peer and Node 22.1.0+.
18
+
19
+ `private: true` prevents accidentally publishing this example. Before publishing your own plugin, choose your package/plugin/contribution IDs, review its implementation and compatibility, update its independent versions, then deliberately remove that flag. The local `npm pack --ignore-scripts` acceptance does not publish anything.
20
+
21
+ ## Configure a new workspace
22
+
23
+ Provide the correct workspace/region API **root** from your own account. Do not append `/api/v1` or `/compatible-mode/v1`. Export your credential through your normal secure environment mechanism before starting Core; do not paste the key into a command argument or this project. Setup receives only the environment variable's **name**:
24
+
25
+ ```bash
26
+ npm run setup -- \
27
+ --directory /absolute/path/to/new-private-wan-workspace \
28
+ --endpoint https://YOUR-WORKSPACE-API-ROOT \
29
+ --credential-env YOUR_WAN_KEY_VARIABLE
30
+ ```
31
+
32
+ The parent directory must already exist. Setup refuses any existing target, even an empty directory, and refuses data inside the plugin project. It writes private Provider/startup files, initializes a new image-generation execution root through the public CLI, and prints exact `startArgv`. It never reads the key, starts a server, imports another harness's settings or submits a generation. Failed setup leaves its partial directory for inspection.
33
+
34
+ Run the printed argv to open Core Web. The default Web login uses Core's normal first-use access passphrase flow. The contributed protocol appears in Provider settings. Read its actual model directory, review the result, explicitly save the supported model and select its image-generation policy. Setup leaves the model list empty and image generation disabled until that selection. Discovering a directory grants no execution authority.
35
+
36
+ In Connections, select **image generation** and the required read/create/execute/stop permissions, then export the ordinary MCP configuration for your client. This Provider reuses the Core image tools; installing it does not add an independent MCP tool or grant a connection new rights. Core owns execution and artifacts. Keep the plugin installed at the configured path and its version unchanged while recovering its original tasks.
37
+
38
+ ## Supported protocol and limits
39
+
40
+ - Identity: `example.wan-image` 0.1.0, contribution `example.wan-image.images` 0.1.0; Plugin/Adapter contract v0 plus factory context v1. Rename these together for your own implementation.
41
+ - `GET /api/v1/models?capabilities=IG&page_no=…&page_size=100`: validate a complete, stable directory of at most 1,000 rows, then expose only actually returned `wan2.6-t2i`. A recognized ID is an implementation boundary, not a fabricated model pool or proof of account availability.
42
+ - `POST /api/v1/services/aigc/image-generation/generation`: `X-DashScope-Async: enable`, `messages` text input, explicit `n` defaulting to 1, optional `width*height`. This is the Wan 2.6 format, not the older Wan 2.5 endpoint. Core's image Run currently requests one image; the adapter accepts 1–4 for other public consumers.
43
+ - Poll only the original `/api/v1/tasks/{task_id}`. Missing submission identity or malformed/oversized submission responses mean **unknown acceptance**. Never retry a POST or switch a model/exit automatically after an ambiguous submission.
44
+ - Download PNG results, at most 8 MiB each, into the exact new staging path supplied by Core. API requests refuse redirects; signed CDN downloads follow redirects through the same borrowed Core network port and receive no API Authorization header. JSON responses are limited to 1 MiB. Upstream details are not reflected into safe errors.
45
+ - No source upload, image edit, remote cancellation or unsupported model fallback. Stopping local waiting does not prove remote cancellation or stopped billing. Remote task IDs/result URLs have the vendor's limited lifetime; persistence cannot recover a vendor-expired result.
46
+ - The adapter owns no agents, background timers or queues. Core resolves the current credential for each factory, owns network routing/scheduling, and drains each full awaited method, including body reads and child requests. Await every request and body before returning; do not keep the borrowed port for later background work or dispose it.
47
+
48
+ The plugin is trusted Node code, not a network/filesystem sandbox. This reference intentionally uses only the borrowed `context.network.fetch` for its HTTP requests and writes only the supplied delivery file.
49
+
50
+ The protocol was checked against the official [Wan image API](https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference) and [model directory API](https://help.aliyun.com/zh/model-studio/list-models) on 2026-11-03. Acceptance uses controlled local responses; it does not certify a live account, region, billing behavior or every Wan model.
@@ -0,0 +1,5 @@
1
+ import type { CorePlugin } from '@mokuyoaxis/dsh-iris/plugins';
2
+ export const PROVIDER_PROTOCOL: 'example.wan-image.images';
3
+ export const SUPPORTED_MODEL: 'wan2.6-t2i';
4
+ declare const plugin: CorePlugin;
5
+ export default plugin;
@@ -0,0 +1,165 @@
1
+ import * as iris from '@mokuyoaxis/dsh-iris/plugins';
2
+ import { writeFileSync } from 'node:fs';
3
+
4
+ if (iris.PROVIDER_FACTORY_CONTEXT_VERSION !== 1 || typeof iris.ProviderContractError !== 'function') {
5
+ throw new Error('Wan Provider starter requires Iris Provider factory context v1. Published 0.3.0-alpha.1 does not include it; use the verified newer Core candidate described in README.');
6
+ }
7
+ const { definePlugin, defineProviderAdapter, ProviderContractError, normalizeMediaEndpoint } = iris;
8
+ export const PROVIDER_PROTOCOL = 'example.wan-image.images';
9
+ export const SUPPORTED_MODEL = 'wan2.6-t2i';
10
+ const JSON_LIMIT = 1024 * 1024, IMAGE_LIMIT = 8 * 1024 * 1024, DIRECTORY_LIMIT = 1000;
11
+
12
+ function failure(message, stage, acceptance, category = 'protocol', details = {}) {
13
+ return new ProviderContractError(message, { stage, acceptance, category, ...details });
14
+ }
15
+ function endpoint(value) {
16
+ const base = normalizeMediaEndpoint(value);
17
+ if (/\/(?:api\/v1|compatible-mode\/v1)$/.test(new URL(base).pathname)) {
18
+ throw failure('Use the workspace API root, without /api/v1 or /compatible-mode/v1.', 'validate', 'not_accepted', 'invalid_request');
19
+ }
20
+ return base;
21
+ }
22
+ function boundedSignal(request) {
23
+ const deadline = AbortSignal.timeout(request.timeoutMs ?? 30000);
24
+ return request.signal ? AbortSignal.any([request.signal, deadline]) : deadline;
25
+ }
26
+ async function readBody(response, limit, stage, acceptance) {
27
+ if (!response.body) throw failure('Response has no body.', stage, acceptance);
28
+ const reader = response.body.getReader(), chunks = []; let size = 0;
29
+ try {
30
+ for (;;) {
31
+ const item = await reader.read(); if (item.done) break;
32
+ size += item.value.byteLength;
33
+ if (size > limit) {
34
+ await reader.cancel(); throw failure('Response exceeds the documented starter size limit.', stage, acceptance);
35
+ }
36
+ chunks.push(Buffer.from(item.value));
37
+ }
38
+ return Buffer.concat(chunks, size);
39
+ } finally { reader.releaseLock(); }
40
+ }
41
+ function json(bytes, stage, acceptance) {
42
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
43
+ catch { throw failure('Provider response is not valid UTF-8 JSON.', stage, acceptance); }
44
+ }
45
+ function httpFailure(response, operation) {
46
+ const ambiguous = [408, 409, 425, 499].includes(response.status);
47
+ const known = operation === 'submit' && response.status >= 400 && response.status < 500 && !ambiguous;
48
+ const acceptance = operation === 'submit' ? known ? 'not_accepted' : 'unknown' : operation === 'discover' ? 'not_accepted' : 'accepted';
49
+ const category = [401, 403].includes(response.status) ? 'authentication' : response.status === 429 ? 'rate_limit' : response.status >= 500 ? 'provider' : 'invalid_request';
50
+ const error = failure(`Wan HTTP request failed (${response.status}).`, operation === 'discover' ? 'validate' : operation, acceptance, category,
51
+ { httpStatus: response.status, retryable: response.status === 429 || response.status >= 500 });
52
+ if (response.status === 429) {
53
+ const value = response.headers.get('retry-after'), seconds = value && Number(value), date = value && Date.parse(value);
54
+ if (Number.isFinite(seconds) && seconds >= 0) error.retryAfterMs = Math.max(1000, seconds * 1000);
55
+ else if (Number.isFinite(date) && date > Date.now()) error.retryAfterMs = date - Date.now();
56
+ }
57
+ return error;
58
+ }
59
+
60
+ export default definePlugin({
61
+ manifest: { contractVersion: 0, id: 'example.wan-image', version: '0.1.0',
62
+ compatibility: { min: '0.3.0-alpha.1', maxExclusive: '0.4.0' },
63
+ contributions: { providers: [{ id: 'images', version: '0.1.0', title: 'Wan 2.6 async image reference', capabilities: ['image-gen'],
64
+ configuration: [{ key: 'baseUrl', type: 'url', label: 'Workspace API root', required: true },
65
+ { key: 'mediaBaseUrl', type: 'url', label: 'Separate media API root', advanced: true },
66
+ { key: 'apiKey', type: 'secret', label: 'API key', required: true }] }] } },
67
+ activate() {
68
+ // No timers, agents or global state to close. Core owns the borrowed network port.
69
+ return { providers: { images: { normalizeEndpoint: endpoint,
70
+ supportsModel: ({ modelId, capability }) => modelId === SUPPORTED_MODEL && capability === 'image-gen',
71
+ supportsImageEdit: () => false,
72
+ createAdapter(provider, context) {
73
+ if (context?.contractVersion !== 1 || typeof context.network?.fetch !== 'function') {
74
+ throw failure('Core must supply Provider factory context v1.', 'validate', 'not_accepted', 'invalid_request');
75
+ }
76
+ const base = endpoint(provider.mediaBaseUrl || provider.baseUrl), key = provider.apiKey || '';
77
+ if (provider.auth !== 'none' && !key) throw failure('Configure an API credential in Core before calling this Provider.', 'validate', 'not_accepted', 'authentication');
78
+ const headers = { 'Content-Type': 'application/json', ...(key ? { Authorization: 'Bearer ' + key } : {}) };
79
+ async function api(route, request, operation, payload) {
80
+ const response = await context.network.fetch(base + route, { method: payload === undefined ? 'GET' : 'POST',
81
+ redirect: 'error', signal: boundedSignal(request), headers: { ...headers, ...(operation === 'submit' ? { 'X-DashScope-Async': 'enable' } : {}) },
82
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }) });
83
+ const acceptance = operation === 'submit' ? 'unknown' : operation === 'discover' ? 'not_accepted' : 'accepted';
84
+ const stage = operation === 'discover' ? 'validate' : operation === 'submit' ? 'response' : operation;
85
+ const bytes = await readBody(response, JSON_LIMIT, stage, acceptance);
86
+ if (!response.ok) throw httpFailure(response, operation);
87
+ return json(bytes, stage, acceptance);
88
+ }
89
+ return defineProviderAdapter({ id: provider.id, protocol: PROVIDER_PROTOCOL, capabilities: ['image'], operations: {
90
+ async discover(request = {}) {
91
+ const models = [], seen = new Set(); let total;
92
+ for (let page = 1; page <= 10; page++) {
93
+ const value = await api(`/api/v1/models?capabilities=IG&page_no=${page}&page_size=100`, request, 'discover');
94
+ const output = value?.output;
95
+ if (!output || !Array.isArray(output.models) || output.models.length > 100 || !Number.isSafeInteger(output.total)
96
+ || output.total < 0 || output.total > DIRECTORY_LIMIT || output.page_no !== page || output.page_size !== 100
97
+ || total !== undefined && total !== output.total) throw failure('Model directory pagination is invalid or changed; keep the previous complete snapshot.', 'validate', 'not_accepted');
98
+ total = output.total;
99
+ for (const model of output.models) {
100
+ if (!model || typeof model.model !== 'string' || !model.model || seen.has(model.model)) throw failure('Model directory contains invalid or duplicate IDs.', 'validate', 'not_accepted');
101
+ seen.add(model.model);
102
+ if (model.model === SUPPORTED_MODEL) models.push({ id: model.model, capabilities: ['image-gen'] });
103
+ }
104
+ if (seen.size === total) return { models };
105
+ if (!output.models.length || seen.size > total) throw failure('Model directory is incomplete.', 'validate', 'not_accepted');
106
+ }
107
+ throw failure('Model directory exceeded the bounded page count.', 'validate', 'not_accepted');
108
+ },
109
+ async submit(request) {
110
+ const { input } = request;
111
+ if (request.capability !== 'image' || request.model !== SUPPORTED_MODEL || input.image !== undefined) {
112
+ throw failure('This reference supports only Wan 2.6 text-to-image; image editing and other models are unsupported.', 'validate', 'not_accepted', 'invalid_request');
113
+ }
114
+ if (typeof input.prompt !== 'string' || !input.prompt.trim() || Array.from(input.prompt).length > 2100
115
+ || input.n !== undefined && (!Number.isSafeInteger(input.n) || input.n < 1 || input.n > 4)
116
+ || input.size !== undefined && (typeof input.size !== 'string' || !/^\d+[x*]\d+$/i.test(input.size))) {
117
+ throw failure('Use a nonempty prompt of at most 2100 characters, n=1–4 and an optional width*height size.', 'validate', 'not_accepted', 'invalid_request');
118
+ }
119
+ const value = await api('/api/v1/services/aigc/image-generation/generation', request, 'submit', { model: request.model,
120
+ input: { messages: [{ role: 'user', content: [{ text: input.prompt }] }] },
121
+ parameters: { n: input.n ?? 1, ...(input.size ? { size: input.size.replace(/x/i, '*') } : {}) } });
122
+ if (typeof value?.output?.task_id !== 'string' || !value.output.task_id.trim()) throw failure('Submission response has no remote task ID; acceptance remains unknown.', 'response', 'unknown');
123
+ return { kind: 'accepted', remoteTaskId: value.output.task_id };
124
+ },
125
+ async poll(request) {
126
+ const value = await api('/api/v1/tasks/' + encodeURIComponent(request.remoteTaskId), request, 'poll'), output = value?.output;
127
+ if (['PENDING', 'RUNNING'].includes(output?.task_status)) return { kind: 'pending', progress: output.task_status };
128
+ if (output?.task_status === 'SUCCEEDED') {
129
+ if (!Array.isArray(output.choices) || output.choices.some(choice => !Array.isArray(choice?.message?.content)
130
+ || choice.message.content.some(item => !item || typeof item !== 'object'))) {
131
+ throw failure('Successful task response contains malformed image choices.', 'poll', 'accepted');
132
+ }
133
+ const images = output.choices.flatMap(choice => choice.message.content).flatMap(item => typeof item.image === 'string' && item.image ? [item.image] : []);
134
+ if (!images?.length || images.length > 4) throw failure('Successful task response has no usable image results.', 'poll', 'accepted');
135
+ return { kind: 'succeeded', artifacts: images.map(url => ({ kind: 'remote-url', url, mediaType: 'image/png' })) };
136
+ }
137
+ const kind = { FAILED: 'failed', CANCELED: 'canceled', UNKNOWN: 'unknown' }[output?.task_status];
138
+ if (kind) return { kind, error: failure('Remote task is ' + kind + '.', 'poll', 'accepted', kind === 'canceled' ? 'aborted' : kind === 'unknown' ? 'unknown' : 'provider') };
139
+ throw failure('Remote task response has an unrecognized status.', 'poll', 'accepted');
140
+ },
141
+ async download(request) {
142
+ if (request.artifact.kind !== 'remote-url') throw failure('Wan result must be a remote image URL.', 'download', 'accepted');
143
+ const url = new URL(request.artifact.url);
144
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw failure('Result URL must use HTTP(S) without embedded credentials.', 'download', 'accepted');
145
+ // Presigned CDN URLs never receive the API Authorization header. Redirects use the same Core port.
146
+ const response = await context.network.fetch(url, { signal: boundedSignal(request) });
147
+ const bytes = await readBody(response, IMAGE_LIMIT, 'download', 'accepted');
148
+ if (!response.ok) throw httpFailure(response, 'download');
149
+ writeFileSync(request.targetPath, bytes, { mode: 0o600, flag: 'wx' });
150
+ return { bytes: bytes.length };
151
+ },
152
+ mapError(error, defaults = {}) {
153
+ if (error instanceof ProviderContractError) return error;
154
+ const aborted = error?.name === 'AbortError', timeout = error?.name === 'TimeoutError';
155
+ return failure('Wan transport or local delivery failed; inspect the original Core Task before taking another action.',
156
+ error?.stage || defaults.stage || 'submit', error?.acceptance || defaults.acceptance || 'unknown',
157
+ error?.category || (aborted ? 'aborted' : timeout ? 'timeout' : ['ENOSPC', 'EDQUOT', 'EACCES', 'EPERM', 'EROFS', 'EIO', 'EEXIST'].includes(error?.code) ? 'local_io' : 'network'),
158
+ { ...(typeof error?.code === 'string' && error.code.startsWith('IRIS_') ? { providerCode: error.code } : {}), retryable: aborted ? false : true });
159
+ }
160
+ }, unsupported: { cancel: 'Remote cancellation is outside this reference; stopping Core waiting does not cancel billing.' },
161
+ unsupportedPreparation: 'This text-to-image protocol does not upload source images.' });
162
+ }
163
+ } } };
164
+ }
165
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "iris-provider-starter-wan",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "An independent Wan 2.6 image Provider reference using the Iris public plugin context.",
6
+ "type": "module",
7
+ "exports": { "types": "./index.d.mts", "default": "./index.mjs" },
8
+ "files": ["index.mjs", "index.d.mts", "scripts", "README.md", "LICENSE"],
9
+ "scripts": { "setup": "node scripts/setup.mjs", "test": "node scripts/smoke.mjs" },
10
+ "peerDependencies": { "@mokuyoaxis/dsh-iris": ">=0.3.0-alpha.1 <0.4.0" },
11
+ "engines": { "node": ">=22.1.0" },
12
+ "license": "MIT"
13
+ }
@@ -0,0 +1,22 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const packageFile = fileURLToPath(import.meta.resolve('@mokuyoaxis/dsh-iris/package.json'));
8
+ const metadata = JSON.parse(await readFile(packageFile, 'utf8'));
9
+ export const coreCli = path.resolve(path.dirname(packageFile), metadata.bin['dsh-iris']);
10
+ export const cleanEnvironment = () => ({ ...process.env, IRIS_IMPORT_WORKBENCH_CONFIG: '', IRIS_CORE_CONNECTION_FILE: '', IRIS_CORE_SCOPES: '', NODE_OPTIONS: '' });
11
+ const execute = promisify(execFile);
12
+ export async function runCore(dataRoot, ...args) {
13
+ return runCli([...args, '--data-root', dataRoot]);
14
+ }
15
+ export async function runCli(args) {
16
+ const { stdout } = await execute(process.execPath, [coreCli, ...args], {
17
+ timeout: 30_000, maxBuffer: 2 * 1024 * 1024, env: cleanEnvironment()
18
+ });
19
+ const value = JSON.parse(stdout);
20
+ if (value.ok === false) throw new Error('Core command failed: ' + JSON.stringify(value));
21
+ return value.result ?? value;
22
+ }
@@ -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
+ }