@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  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/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. package/user_guide.md +5 -1
package/lib/local-api.js CHANGED
@@ -5,19 +5,44 @@ import path from 'node:path';
5
5
  import { createApplicationRuntime } from './application-runtime.js';
6
6
  import { normalizeCoreOptions } from './core-contract.js';
7
7
  import { createCoreRuntime } from './core-runtime.js';
8
- import { inspectFlowRoot } from './flow-store.js';
9
- import { inspectCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
10
- import { imageCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
8
+ import { inspectFlowRoot, listFlowRuns, readFlowRun } from './flow-store.js';
9
+ import { inspectCoreArtifactMetadata, readCoreArtifactBytes, listCoreArtifacts } from './core-artifacts.js';
10
+ import { isPluginSourceImage } from './plugin-source-image.js';
11
+ import { imageCandidatesFromCatalog, ttsCandidatesFromCatalog, transcribeCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
12
+ import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, isAudioRecipe } from './audio-flow-input.js';
13
+ import { CREATION_KINDS } from './creation-request-store.js';
11
14
  import { supportsImageEdit } from './provider-protocol.js';
15
+ import { projectComfyWorkflow, compileComfyWorkflow } from './comfyui-workflow.js';
12
16
  import { projectLocalApiFlow, projectLocalApiRunSummary } from './local-api-projection.js';
13
17
  import { inspectCreationRoot } from './creation-request-store.js';
14
18
  import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
19
+ import { DEFAULT_PANEL, LOCAL_WEB_CSP } from './local-web-assets.js';
20
+ import { loadPanelExtension } from './panel-extension.js';
21
+ import { createPluginPanelAssembly } from './plugin-web-assets.js';
22
+ import { executeConfigCommand } from './provider-config-service.js';
23
+ import { projectProviderDiagnostics, providerDependencies, providerRemovalImpact } from './provider-diagnostics.js';
24
+ import { createProviderMonitor } from './provider-monitor.js';
25
+ import { configuredImagePorts } from './configured-image-runtime.js';
26
+ import { ProviderContractError } from './provider-contract.js';
27
+ import { createProviderRegistry } from './provider-extensions.js';
28
+ import { createOperationRunner } from './operation-runner.js';
29
+ import { inspectExecutionRoot } from './operation-store.js';
30
+ import { readConnectionGrants, createConnectionRegistry } from './core-connections.js';
31
+ import { createManagedConnectionStore } from './core-connection-store.js';
32
+ import { CONNECTION_TEMPLATE_EXAMPLE } from './connection-template.js';
33
+ import { checkedBridgeCapabilities, DEFAULT_BRIDGE_CAPABILITIES } from './core-bridge-selection.js';
34
+ import { createCoreCapabilityCatalog, projectCoreSessionOperations, CORE_CAPABILITY_GROUPS } from './core-capability-catalog.js';
35
+ import { MCP_CONNECTION_PRESETS, MCP_CONFIG_FORMATS } from './mcp-delivery.js';
36
+ import { checkedPluginSelection, hasPluginSelection } from './plugin-selection.js';
37
+ import { readAllOperations, projectOperation } from './operation-store.js';
38
+ import { createWorkbenchAuth, WorkbenchAuthError } from './workbench-auth.js';
15
39
 
16
40
  export const MAX_LOCAL_API_IMAGE_BYTES = 64 * 1024 * 1024;
17
41
  const RUN = /^run_[a-f0-9]{24}$/;
18
42
  const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
19
43
  const KEY = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
20
44
  const IMAGE_TYPES = new Map([['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp']]);
45
+ const AUDIO_MEDIA_TYPES = new Map(Object.entries(AUDIO_TYPES));
21
46
  const digest = value => crypto.createHash('sha256').update(value).digest();
22
47
  const CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
23
48
 
@@ -28,28 +53,61 @@ function fail(code, message, status) { throw new LocalApiError(code, message, st
28
53
 
29
54
  function httpError(error) {
30
55
  if (error instanceof LocalApiError) return error;
56
+ if (error instanceof WorkbenchAuthError) return new LocalApiError(error.code, error.message, error.status);
57
+ if (/^IRIS_PLUGIN_/.test(error?.code || '')) return new LocalApiError(error.code,
58
+ error.code === 'IRIS_PLUGIN_INPUT_INVALID' ? '业务输入不符合当前插件参数要求' : '插件业务未授权、缺失或版本不一致;请读取原 Operation 并核对准确插件版本',
59
+ error.code === 'IRIS_PLUGIN_INPUT_INVALID' ? 400 : error.code === 'IRIS_PLUGIN_OPERATION_DENIED' ? 403 : 409);
60
+ if (['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY'].includes(error?.code)) return new LocalApiError(error.code,
61
+ '配置已变化或有其他写入,请保留草稿并重新读取后核对', 409);
62
+ if (error?.code === 'IRIS_CONFIG_INPUT_INVALID' || error?.code === 'IRIS_FUNCTION_POLICY_INVALID') return new LocalApiError('IRIS_CONFIG_INPUT_INVALID',
63
+ '设置无效,请检查地址、协议、模型用途和密钥动作', 400);
64
+ if (error?.code === 'IRIS_OPERATION_NOT_FOUND') return new LocalApiError(error.code, 'Operation 不存在', 404);
65
+ if (error?.code === 'IRIS_OPERATION_KEY_CONFLICT') return new LocalApiError(error.code, '该 UUID 已绑定其他业务、输入或依赖,请先查询原操作', 409);
66
+ if (error?.code === 'IRIS_OPERATION_QUEUE_FULL') return new LocalApiError(error.code, '活动操作已达上限,本次未接收', 429);
67
+ if (error?.code === 'IRIS_OPERATION_INPUT_INVALID' || error?.code === 'IRIS_OPERATION_KEY_INVALID') return new LocalApiError(error.code, 'Operation 输入无效', 400);
68
+ if (/^IRIS_CONNECTION_/.test(error?.code || '')) {
69
+ const status = error.code === 'IRIS_CONNECTION_AUTH_REQUIRED' ? 401 : error.code === 'IRIS_CONNECTION_CAPABILITY_DENIED' ? 403
70
+ : error.code === 'IRIS_CONNECTION_BUSY' ? 429 : error.code === 'IRIS_CONNECTION_INPUT_INVALID' ? 400 : 409;
71
+ const message = {
72
+ IRIS_CONNECTION_AUTH_REQUIRED: '连接凭据无效或已撤销,请由所有者核对并重新导出配置',
73
+ IRIS_CONNECTION_CAPABILITY_DENIED: '所选功能、工具或权限未开放,请核对已保存授权和服务开关',
74
+ IRIS_CONNECTION_INPUT_INVALID: '连接请求无效,请核对功能、权限与配置格式;预设或模板不能与手动格式混用',
75
+ IRIS_CONNECTION_CONFIG_CHANGED: '连接或模板配置已变化,请刷新后核对原记录',
76
+ IRIS_CONNECTION_REVOKED: '连接已撤销,无法继续导出或使用',
77
+ IRIS_CONNECTION_HANDSHAKE_REQUIRED: '连接握手已失效,请重新连接原 Core 服务'
78
+ }[error.code];
79
+ return new LocalApiError(error.code, message || '连接身份、授权或握手状态不符合当前服务要求', status);
80
+ }
81
+ if (/^IRIS_EXECUTION_/.test(error?.code || '')) return new LocalApiError(error.code, '执行服务未就绪,请保留原 Operation 并在本机检查', 503);
31
82
  if (error?.code === 'IRIS_CORE_DATA_ROOT_BUSY') return new LocalApiError('IRIS_API_ROOT_BUSY', '该数据根已有活动写者,请稍后显式重试', 409);
32
83
  if (error?.code === 'IRIS_CREATION_RESULT_UNAVAILABLE') return new LocalApiError('IRIS_API_RESULT_UNAVAILABLE', '原创建结果已不可用;同 Key 不会重新创建或复活作品', 409);
33
84
  if (error?.code === 'IRIS_CREATION_KEY_CONFLICT') return new LocalApiError('IRIS_API_KEY_CONFLICT', '该 Key 已绑定其他输入', 409);
85
+ if (error?.code === 'IRIS_CREATION_NOT_FOUND') return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此创建请求', 404);
34
86
  if (['IRIS_CREATION_KEY_INVALID', 'IRIS_CREATION_INPUT_INVALID', 'IRIS_GENERATION_INPUT_INVALID',
35
- 'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID'].includes(error?.code)) {
87
+ 'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID', 'IRIS_COMFYUI_PARAMETERS_INVALID'].includes(error?.code)) {
36
88
  return new LocalApiError('IRIS_API_INPUT_INVALID', '创作输入或静态图片无效,请检查来源、模型和参数', 400);
37
89
  }
38
90
  if (error?.code === 'IRIS_CREATION_MEDIA_MISMATCH') return new LocalApiError('IRIS_API_MEDIA_UNSUPPORTED', '图片真实格式与 Content-Type 不一致', 415);
39
91
  if (/^IRIS_CREATION_/.test(error?.code || '')) return new LocalApiError('IRIS_API_RECORD_CONFLICT', '创建请求证据不一致,请在本机检查原记录', 409);
40
92
  if (['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error?.code)) {
41
- return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或图片', 404);
93
+ return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或作品', 404);
42
94
  }
95
+ if (error?.code === 'IRIS_FLOW_REVISION_CONFLICT') return new LocalApiError('IRIS_API_RUN_CHANGED', '流程已更新,请读取当前版本后重新选择', 409);
96
+ if (error?.code === 'IRIS_FLOW_NOT_READY') return new LocalApiError('IRIS_API_RUN_NOT_READY', '请先保存任务结果;图片编辑还需完成比较', 409);
97
+ if (error?.code === 'IRIS_FLOW_GENERATION_ROOT_REQUIRED') return new LocalApiError(error.code, '文生图需要新建 image-generation 执行根;已有根不自动迁移', 409);
98
+ if (error?.code === 'IRIS_FLOW_AUDIO_ROOT_REQUIRED') return new LocalApiError(error.code, '音频需要新建 execution init --audio 执行根;已有根不自动迁移', 409);
99
+ if (error?.code === 'IRIS_AUDIO_INPUT_INVALID') return new LocalApiError(error.code, '音频或创作输入无效:请核对模型、文字及WAV/MP3/OGG/FLAC容器和类型', 400);
100
+ if (error?.code === 'IRIS_FLOW_SELECTION_INVALID') return new LocalApiError('IRIS_API_SELECTION_INVALID', '只能选择此流程的原图或生成结果', 409);
43
101
  if (/^IRIS_PROVIDER_/.test(error?.code || '')) {
44
102
  return new LocalApiError('IRIS_API_CONFIG_UNAVAILABLE', '模型配置不可用,请在本机检查显式配置', 500);
45
103
  }
46
104
  if (error?.code === 'IRIS_APP_CLOSED' || error?.name === 'AbortError' || /^IRIS_CORE_DATA_ROOT_/.test(error?.code || '')) {
47
105
  return new LocalApiError('IRIS_API_UNAVAILABLE', '服务正在关闭或数据根不可用', 503);
48
106
  }
49
- if (/^IRIS_(?:FLOW|ARTIFACT|TASK)_/.test(error?.code || '')) {
50
- return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程或作品证据不一致,请在本机核对原记录', 409);
107
+ if (/^IRIS_(?:FLOW|ARTIFACT|TASK|OPERATION)_/.test(error?.code || '')) {
108
+ return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程、作品或操作记录不一致,请在本机核对原记录', 409);
51
109
  }
52
- return new LocalApiError('IRIS_API_INTERNAL', '读取失败,请使用关联 ID 核对本机诊断', 500);
110
+ return new LocalApiError('IRIS_API_INTERNAL', '操作失败,请使用关联 ID 核对本机诊断', 500);
53
111
  }
54
112
 
55
113
  function reportUnexpected(error, requestId) {
@@ -100,43 +158,388 @@ function send(request, response, status, bytes, headers = {}) {
100
158
  const json = (request, response, value, status = 200) => send(request, response, status, Buffer.from(JSON.stringify(value)));
101
159
 
102
160
  /** Internal Host, read-only by default. Construction performs no I/O and never binds a port. */
103
- export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false } = {}) {
161
+ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false, serveWorkbench = false,
162
+ allowExecution = false, allowSelection = false, allowMcp = false, connectionsFile, executionPorts, allowConnectionManagement = false, connectionStore, providerRegistry, pluginRegistry,
163
+ allowProviderManagement = false, panelManifestPath, panelContributions, allowedOperations, workbenchAuth } = {}) {
164
+ if (typeof allowExecution !== 'boolean') throw new TypeError('allowExecution 必须是布尔值');
165
+ if (typeof allowSelection !== 'boolean') throw new TypeError('allowSelection 必须是布尔值');
166
+ if (typeof allowMcp !== 'boolean') throw new TypeError('allowMcp 必须是布尔值');
167
+ if (typeof allowConnectionManagement !== 'boolean') throw new TypeError('allowConnectionManagement 必须是布尔值');
168
+ if (allowConnectionManagement !== Boolean(connectionStore)) fail('IRIS_API_CONFIG_INVALID', '连接管理须显式开启并指定专用目录');
169
+ if (allowMcp && !connectionsFile && !connectionStore) fail('IRIS_API_CONFIG_INVALID', 'MCP HTTP 需要明确的连接授权文件或管理目录');
170
+ if (allowExecution) allowLocalWrites = true;
104
171
  if (typeof allowLocalWrites !== 'boolean') throw new TypeError('allowLocalWrites 必须是布尔值');
172
+ if (typeof serveWorkbench !== 'boolean') throw new TypeError('serveWorkbench 必须是布尔值');
173
+ if (workbenchAuth !== undefined && (!serveWorkbench || !workbenchAuth || Object.keys(workbenchAuth).join(',') !== 'directory'
174
+ || typeof workbenchAuth.directory !== 'string' || !path.isAbsolute(workbenchAuth.directory))) {
175
+ fail('IRIS_API_CONFIG_INVALID', '工作台口令认证只接受明确的私有 directory,且仅用于 web serve');
176
+ }
177
+ if (typeof allowProviderManagement !== 'boolean') throw new TypeError('allowProviderManagement 必须是布尔值');
178
+ if (pluginRegistry && providerRegistry && providerRegistry !== pluginRegistry.providers) fail('IRIS_API_CONFIG_INVALID', '插件与 Provider 注册器须来自同一装配实例');
179
+ const suppliedRegistry = pluginRegistry?.providers ?? providerRegistry;
180
+ const capabilityCatalog = createCoreCapabilityCatalog({ registry: pluginRegistry });
181
+ const pluginExecution = allowedOperations !== undefined;
182
+ const selectedOperations = pluginExecution ? capabilityCatalog.selectOperations(allowedOperations) : [];
183
+ const allowedPluginIds = Object.freeze(selectedOperations.map(item => item.id));
184
+ if (allowProviderManagement && !providerConfigPath) fail('IRIS_API_CONFIG_INVALID', '供应商管理需要显式 Provider 配置');
185
+ if (panelManifestPath !== undefined && (!serveWorkbench || typeof panelManifestPath !== 'string' || !path.isAbsolute(panelManifestPath))) fail('IRIS_API_CONFIG_INVALID', '替代 Panel 只用于工作台,须指定绝对清单路径');
186
+ if (panelContributions !== undefined && (!serveWorkbench || panelManifestPath !== undefined)) fail('IRIS_API_CONFIG_INVALID', '组合贡献用于默认工作台;请在组合挂载与整套 Panel 替换之间明确选择');
105
187
  const suppliedRoot = normalizeCoreOptions({ dataRoot, mode: 'reader' }).dataRoot;
106
188
  if (providerConfigPath !== undefined && (typeof providerConfigPath !== 'string' || !path.isAbsolute(providerConfigPath))) {
107
189
  throw new LocalApiError('IRIS_API_CONFIG_INVALID', 'Provider 配置必须是显式绝对路径');
108
190
  }
191
+ if (allowExecution && !providerConfigPath && !executionPorts) fail('IRIS_API_CONFIG_INVALID', '执行模式需要显式 Provider 配置');
192
+ if (connectionsFile !== undefined && (typeof connectionsFile !== 'string' || !path.isAbsolute(connectionsFile))) fail('IRIS_API_CONFIG_INVALID', '连接授权须为显式根外文件');
109
193
  let state = 'new', origin = null, tokenDigest = null, application, server, physicalRoot;
110
- let startPromise, disposal, closeRequested = false, activeReads = 0, activeUploads = 0, localWrites = 0;
194
+ let browserAuth;
195
+ let providerMonitor;
196
+ let runner, runnerStarted = false, connections, mcp, managed, imageGenerationEnabled = false, audioEnabled = false;
197
+ let registry = suppliedRegistry, panel = DEFAULT_PANEL, pluginPanel, providerManagementBusy = false;
198
+ const capabilityFacts = () => capabilityCatalog.snapshot({ audioEnabled, panelContributions: pluginPanel?.selection, allowedOperations: allowedPluginIds });
199
+ const changedAuthorizations = new Set();
200
+ let loginTicket = null;
201
+ const browserSessions = new Set();
202
+ let startPromise, disposal, closeRequested = false, cleanupFailed = false, activeReads = 0, activeUploads = 0, localWrites = 0;
111
203
  const jobs = new Set(), instanceId = crypto.randomUUID();
112
204
  let resolveClosed;
113
205
  const closed = new Promise(resolve => { resolveClosed = resolve; });
114
- const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites, activeReads });
206
+ const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites && !allowSelection, activeReads,
207
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
208
+ ...(allowMcp ? { mcpEndpoint: origin && origin + '/mcp' } : {}),
209
+ ...(allowExecution || pluginExecution ? { executionEnabled: runner?.snapshot().executionEnabled === true } : {}) });
210
+ const availableScopes = ['read', ...(allowLocalWrites ? ['create'] : []), ...(allowExecution || pluginExecution ? ['execute', 'stop'] : [])];
211
+ // Sharing the execute scope with plugin businesses must not enable the read-only media runner.
212
+ const availableTools = () => {
213
+ const facts = capabilityFacts();
214
+ return [...facts.tools, ...facts.pluginTools].filter(tool => tool.scope !== 'execute'
215
+ || (tool.name === 'iris_core_plugin_call' ? pluginExecution : !pluginExecution || allowExecution)).map(tool => tool.name);
216
+ };
217
+ const availableCapabilities = () => capabilityCatalog.snapshot({ audioEnabled }).groups;
115
218
 
116
- function authorize(request) {
219
+ function issueLoginLink() {
220
+ const ticket = crypto.randomBytes(32).toString('base64url');
221
+ const expiresAt = Date.now() + 10 * 60 * 1000;
222
+ loginTicket = { digest: digest(ticket), expiresAt };
223
+ return { loginUrl: origin + '/#login=' + ticket, loginExpiresAt: new Date(expiresAt).toISOString() };
224
+ }
225
+
226
+ function redeemLoginLink(ticket) {
227
+ if (!loginTicket || Date.now() >= loginTicket.expiresAt || !crypto.timingSafeEqual(digest(ticket), loginTicket.digest)) {
228
+ fail('IRIS_WEB_LOGIN_EXPIRED', '登录链接已失效或已使用,请从启动终端获取新链接,或展开备用登录', 401);
229
+ }
230
+ if (browserSessions.size >= 32) fail('IRIS_WEB_LOGIN_LIMIT', '本次服务的浏览器登录已达上限,请使用备用 Token 登录', 429);
231
+ // Consume synchronously, before any response I/O; concurrent redemption can succeed only once.
232
+ loginTicket = null;
233
+ const accessToken = crypto.randomBytes(32).toString('base64url');
234
+ browserSessions.add(digest(accessToken).toString('hex'));
235
+ return { accessToken };
236
+ }
237
+
238
+ function authorizeOrigin(request, documentNavigation = false) {
117
239
  const headers = request.headers;
118
240
  if (headerCount(request, 'host') !== 1 || headers.host !== new URL(origin).host) {
119
241
  fail('IRIS_API_HOST_DENIED', '请求地址不可信,请使用启动时给出的本机地址', 403);
120
242
  }
121
243
  if (headerCount(request, 'origin') > 1 || (headers.origin !== undefined && headers.origin !== origin)
122
- || (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site']))) {
244
+ || (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site'])
245
+ && !(documentNavigation && headers['sec-fetch-mode'] === 'navigate' && headers['sec-fetch-dest'] === 'document'))) {
123
246
  fail('IRIS_API_ORIGIN_DENIED', '请求来源不可信', 403);
124
247
  }
248
+ }
249
+
250
+ function authorize(request) {
251
+ authorizeOrigin(request);
252
+ const headers = request.headers;
125
253
  const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(headers.authorization || '');
126
- if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)) {
127
- fail('IRIS_API_AUTH_REQUIRED', '需要有效的本机会话 Token', 401);
254
+ if (match && headerCount(request, 'iris-connection-id') === 1 && connections) {
255
+ return { connectionId: connections.identify(headers['iris-connection-id'], match[1]), runtimeInstanceId: headers['iris-runtime-id'] };
256
+ }
257
+ if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest) && !browserSessions.has(digest(match[1]).toString('hex')) && !browserAuth?.authenticate(match[1])) {
258
+ fail('IRIS_API_AUTH_REQUIRED', browserAuth ? '请用访问口令重新登录工作台' : '需要有效的本机会话 Token', 401);
259
+ }
260
+ if (headers['iris-connection-id'] !== undefined || headers['iris-runtime-id'] !== undefined) fail('IRIS_API_AUTH_REQUIRED', '连接头字段无效', 401);
261
+ return { owner: true };
262
+ }
263
+ function scope(caller, required, tool) {
264
+ if (!availableScopes.includes(required)) fail('IRIS_API_READ_ONLY', '此服务没有开启该操作', 405);
265
+ return caller.owner ? availableScopes : connections.authorize(caller.connectionId, caller.runtimeInstanceId, required, tool);
266
+ }
267
+ async function requireFlowCapability(caller, runId) {
268
+ if (caller.owner) return;
269
+ const run = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => readFlowRun(dataRoot, runId)));
270
+ if (!allowsRun(caller, run)) {
271
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接未选择原流程所需的媒体能力', 403);
272
+ }
273
+ }
274
+ function requirePluginChoice(caller, dependency) {
275
+ if (!caller.owner && !hasPluginSelection(connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [], dependency)) {
276
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接没有选择该业务及准确版本', 403);
277
+ }
278
+ }
279
+ async function requireOperationCapability(caller, operation) {
280
+ if (operation.action === 'plugin.execute') return requirePluginChoice(caller, operation.dependency);
281
+ return requireFlowCapability(caller, operation.runId);
282
+ }
283
+ function allowsRun(caller, run) {
284
+ return caller.owner || CORE_CAPABILITY_GROUPS.some(group => group.recipe === run.recipe
285
+ && connections.selection(caller.connectionId, caller.runtimeInstanceId).capabilities.includes(group.id));
286
+ }
287
+ async function requireArtifactCapability(caller, artifactId) {
288
+ if (caller.owner) return;
289
+ const metadata = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => inspectCoreArtifactMetadata(dataRoot, artifactId)));
290
+ const selection = connections.selection(caller.connectionId, caller.runtimeInstanceId);
291
+ if (isPluginSourceImage(metadata) && (selection.pluginOperations || []).some(pin => selectedOperations.some(item => hasPluginSelection([pin], item.dependency) && item.artifactInputs?.length))) return;
292
+ if (metadata.metadata?.pluginOperationId) {
293
+ const record = await application.execute('operation.inspect', { id: metadata.metadata.pluginOperationId });
294
+ requirePluginChoice(caller, record.operation.dependency); return;
295
+ }
296
+ const references = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => listFlowRuns(dataRoot)))
297
+ .then(runs => runs.filter(run => [run.source, run.output, run.comparison, run.selection].some(ref => ref?.artifactId === artifactId)));
298
+ if (references.length) {
299
+ if (references.some(run => allowsRun(caller, run))) return;
300
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此作品所属流程的媒体能力未被选择', 403);
301
+ }
302
+ // Delivery can commit a Task artifact before the Run's output checkpoint is saved.
303
+ if (metadata.metadata?.taskId) {
304
+ const task = await application.execute('task.inspect', { task_id: metadata.metadata.taskId });
305
+ if (task.task.flowBinding) { await requireFlowCapability(caller, task.task.flowBinding.runId); return; }
306
+ }
307
+ // Unbound imports use the existing media/tool grant. Plugin control tools
308
+ // alone only grant the declared eligible source images handled above.
309
+ if (CORE_CAPABILITY_GROUPS.some(group => selection.capabilities.includes(group.id)
310
+ && (metadata.mediaType.startsWith('image/') ? group.media === 'image'
311
+ : metadata.mediaType.startsWith('audio/') ? group.requiresAudio
312
+ : metadata.mediaType === 'text/plain' && group.media === 'transcribe'))) return;
313
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接没有选择该作品的媒体功能或图片来源用途', 403);
314
+ }
315
+
316
+ async function smallJson(request, limit = 8192) {
317
+ if (headerCount(request, 'content-type') !== 1 || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(request.headers['content-type'])
318
+ || request.headers['content-encoding'] !== undefined) fail('IRIS_API_INPUT_INVALID', '需要 UTF-8 JSON 正文');
319
+ if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文过大', 413);
320
+ const chunks = []; let size = 0;
321
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
322
+ size += chunk.length; if (size > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文过大', 413); chunks.push(chunk);
323
+ }
324
+ if (Object.keys(request.trailers).length || state !== 'open') fail('IRIS_API_INPUT_INVALID', '请求已失效');
325
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size))); }
326
+ catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效 JSON'); }
327
+ }
328
+
329
+ async function specialPost(request, response, url, caller) {
330
+ query(url);
331
+ if (url.pathname === '/api/v0/plugin-operations' && pluginExecution) {
332
+ scope(caller, 'execute', 'iris_core_plugin_call');
333
+ if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) fail('IRIS_API_INPUT_INVALID', '需要稳定 UUIDv4 Key');
334
+ const value = await smallJson(request, 65536);
335
+ if (!value || Object.keys(value).sort().join(',') !== 'dependency,input,operationId') fail('IRIS_API_INPUT_INVALID', '需要准确业务、依赖和输入');
336
+ const pin = checkedPluginSelection([value.dependency])[0];
337
+ requirePluginChoice(caller, pin);
338
+ if (pin.contributionId !== value.operationId) fail('IRIS_API_INPUT_INVALID', '业务与依赖不一致');
339
+ const authorize = () => {
340
+ if (!allowedPluginIds.includes(value.operationId)) fail('IRIS_PLUGIN_OPERATION_DENIED', '此业务未被服务明确启用', 403);
341
+ if (!caller.owner) connections.authorizePlugin(caller.connectionId, pin);
342
+ };
343
+ return json(request, response, await runner.submit({ ...value, key: request.headers['idempotency-key'] }, { authorize }), 202);
344
+ }
345
+ if (url.pathname.startsWith('/api/v0/connection-management')) return manage(request, response, url, caller);
346
+ if (url.pathname.startsWith('/api/v0/provider-management')) return manageProviders(request, response, url, caller);
347
+ const selecting = /^\/api\/v0\/runs\/([^/]+)\/selection$/.exec(url.pathname);
348
+ if (selecting) return selectRun(request, response, selecting[1], caller);
349
+ const connection = /^\/api\/v0\/connections\/([a-f0-9-]{36})\/(handshake|disconnect|client-info)$/.exec(url.pathname);
350
+ if (connection && connections) {
351
+ const value = await smallJson(request);
352
+ if (caller.owner && connection[2] === 'disconnect') {
353
+ if (!value || Object.keys(value).length !== 1 || typeof value.runtimeInstanceId !== 'string') fail('IRIS_API_INPUT_INVALID', '需要明确断开的 runtimeInstanceId');
354
+ return json(request, response, connections.disconnect(connection[1], value.runtimeInstanceId));
355
+ }
356
+ if (caller.owner || caller.connectionId !== connection[1] || headerCount(request, 'iris-runtime-id') !== 1) fail('IRIS_API_AUTH_REQUIRED', '连接身份不一致', 401);
357
+ if (connection[2] === 'handshake') {
358
+ if (value?.runtimeInstanceId !== caller.runtimeInstanceId) fail('IRIS_API_INPUT_INVALID', '实例头字段与握手不一致');
359
+ return json(request, response, connections.handshake(caller.connectionId, value));
360
+ }
361
+ if (connection[2] === 'client-info') return json(request, response, connections.clientInfo(caller.connectionId, caller.runtimeInstanceId, value));
362
+ if (!value || Array.isArray(value) || Object.keys(value).length) fail('IRIS_API_INPUT_INVALID', '断开操作只接受空对象');
363
+ return json(request, response, connections.disconnect(caller.connectionId, caller.runtimeInstanceId));
364
+ }
365
+ const continuing = /^\/api\/v0\/runs\/(run_[a-f0-9]{24})\/continue$/.exec(url.pathname);
366
+ const stopping = /^\/api\/v0\/operations\/(op_[a-f0-9]{64})\/cancel$/.exec(url.pathname);
367
+ if ((allowExecution && continuing) || (allowExecution || pluginExecution) && stopping) {
368
+ scope(caller, continuing ? 'execute' : 'stop', continuing ? 'iris_core_continue' : 'iris_core_stop');
369
+ const value = await smallJson(request);
370
+ if (!value || typeof value !== 'object' || Array.isArray(value)
371
+ || (continuing ? Object.keys(value).length !== 1 || !Object.hasOwn(value, 'expectedRevision')
372
+ : Object.keys(value).length)) fail('IRIS_API_INPUT_INVALID', '操作正文包含不支持的字段');
373
+ if (stopping) {
374
+ const original = await application.execute('operation.inspect', { id: stopping[1] });
375
+ await requireOperationCapability(caller, original.operation);
376
+ return json(request, response, await runner.stop(stopping[1]));
377
+ }
378
+ if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) fail('IRIS_API_INPUT_INVALID', '需要单个小写 UUIDv4 Key');
379
+ await requireFlowCapability(caller, continuing[1]);
380
+ const result = await runner.submit({ key: request.headers['idempotency-key'], runId: continuing[1], expectedRunRevision: value.expectedRevision });
381
+ return json(request, response, result, result.idempotentReplay ? 200 : 202);
382
+ }
383
+ scope(caller, 'create', ({ '/api/v0/images': 'iris_core_import_image', '/api/v0/runs': 'iris_core_create_run',
384
+ '/api/v0/generations': 'iris_core_create_generation', '/api/v0/audio': 'iris_core_import_audio',
385
+ '/api/v0/speeches': 'iris_core_create_speech', '/api/v0/transcriptions': 'iris_core_create_transcription' })[url.pathname]);
386
+ return create(request, response, url);
387
+ }
388
+
389
+ async function selectRun(request, response, runId, caller) {
390
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以选择作品', 403);
391
+ if (!allowSelection) fail('IRIS_API_READ_ONLY', '此服务没有开启作品选择', 405);
392
+ if (!RUN.test(runId)) fail('IRIS_API_INPUT_INVALID', '流程 ID 无效');
393
+ const controller = new AbortController();
394
+ const disconnect = () => { if (!response.writableFinished) controller.abort(); };
395
+ response.once('close', disconnect);
396
+ try {
397
+ const value = await smallJson(request);
398
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 2
399
+ || typeof value.artifactId !== 'string' || !ARTIFACT.test(value.artifactId)
400
+ || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 1) {
401
+ fail('IRIS_API_INPUT_INVALID', '选择需要作品 ID 与正整数 expectedRevision');
402
+ }
403
+ if (response.destroyed) controller.abort();
404
+ controller.signal.throwIfAborted();
405
+ const writes = application.snapshot(), execution = runner?.snapshot();
406
+ if (writes.activeWrites || writes.queuedWrites || execution?.active || execution?.queued) {
407
+ fail('IRIS_API_ROOT_BUSY', '当前有活动写入,请读取状态后明确重试选择', 409);
408
+ }
409
+ const facts = await application.execute('flow.select', { run_id: runId, artifact_id: value.artifactId,
410
+ expected_revision: value.expectedRevision }, { signal: controller.signal });
411
+ return await json(request, response, projectLocalApiFlow(facts));
412
+ } catch (error) {
413
+ if (error?.code === 'ECONNRESET' && request.aborted) { controller.abort(); controller.signal.throwIfAborted(); }
414
+ throw error;
415
+ } finally { response.off('close', disconnect); }
416
+ }
417
+
418
+ function managementFacts() {
419
+ const runtime = connections.list().connections, stored = managed.list(), ids = new Set(stored.map(item => item.connectionId));
420
+ return { contractVersion: 0, connections: [
421
+ ...stored.map(item => ({ ...item, instances: runtime.find(value => value.connectionId === item.connectionId)?.instances || [] })),
422
+ ...runtime.filter(item => !ids.has(item.connectionId)).map(item => ({ ...item, managed: false, state: 'configured' }))
423
+ ] };
424
+ }
425
+ async function manageProviders(request, response, url, caller) {
426
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以管理供应商', 403);
427
+ if (!allowProviderManagement) fail('IRIS_API_READ_ONLY', '此服务未开启供应商管理', 405);
428
+ query(url);
429
+ if (['GET', 'HEAD'].includes(request.method) && url.pathname === '/api/v0/provider-management/settings') {
430
+ return json(request, response, await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry }));
431
+ }
432
+ const monitor = () => providerMonitor ||= createProviderMonitor({ configPath: providerConfigPath, registry });
433
+ if (['GET', 'HEAD'].includes(request.method) && url.pathname === '/api/v0/provider-management/diagnostics') {
434
+ const settings = await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry });
435
+ return json(request, response, { ...projectProviderDiagnostics(loadProviderCatalog(providerConfigPath), registry), revision: settings.revision,
436
+ monitors: monitor().snapshot(), execution: allowExecution, imageGeneration: imageGenerationEnabled, audio: audioEnabled });
128
437
  }
438
+ const command = { '/api/v0/provider-management/save': 'settings.save', '/api/v0/provider-management/discover': 'settings.discover',
439
+ '/api/v0/provider-management/policies': 'settings.policies', '/api/v0/provider-management/enabled': 'settings.enabled',
440
+ '/api/v0/provider-management/remove': 'settings.remove', '/api/v0/provider-management/impact': 'impact',
441
+ '/api/v0/provider-management/check': 'check', '/api/v0/provider-management/monitor': 'monitor',
442
+ '/api/v0/provider-management/workflow-check': 'settings.workflow-check' }[url.pathname];
443
+ if (request.method !== 'POST' || !command) fail('IRIS_API_NOT_FOUND', '没有此供应商管理操作', 404);
444
+ if (providerManagementBusy) fail('IRIS_API_BUSY', '供应商设置正在处理,请等待完成后再操作', 429);
445
+ providerManagementBusy = true;
446
+ const controller = new AbortController(), aborted = () => { if (!response.writableFinished) controller.abort(); };
447
+ response.once('close', aborted);
448
+ try {
449
+ const value = await smallJson(request, 64 * 1024);
450
+ controller.signal.throwIfAborted();
451
+ if (['impact', 'check', 'monitor'].includes(command)) {
452
+ const fields = command === 'monitor' ? ['expectedRevision', 'providerId', 'enabled', 'intervalSeconds'] : ['expectedRevision', 'providerId'];
453
+ if (!value || Array.isArray(value) || Object.keys(value).length !== fields.length || fields.some(key => !Object.hasOwn(value, key))) fail('IRIS_API_INPUT_INVALID', '供应商操作字段无效');
454
+ const settings = await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry });
455
+ if (value.expectedRevision !== settings.revision) fail('IRIS_CONFIG_CHANGED', '配置已变化,请重新读取后操作', 409);
456
+ if (!settings.providers.some(provider => provider.id === value.providerId)) fail('IRIS_API_INPUT_INVALID', '供应商不存在');
457
+ if (command === 'check') return json(request, response, await monitor().check(value.providerId, value.expectedRevision, { signal: controller.signal }));
458
+ if (command === 'monitor') return json(request, response, monitor().configure(value));
459
+ const dependencies = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => providerDependencies(dataRoot, value.providerId)), { signal: controller.signal });
460
+ return json(request, response, { contractVersion: 0, revision: settings.revision, providerId: value.providerId,
461
+ ...providerRemovalImpact(loadProviderCatalog(providerConfigPath), value.providerId), ...dependencies });
462
+ }
463
+ if (command === 'settings.remove') {
464
+ if (!value || Array.isArray(value) || Object.keys(value).sort().join(',') !== 'confirm,expectedRevision,providerId'
465
+ || typeof value.providerId !== 'string' || !value.providerId || value.confirm !== true) fail('IRIS_API_INPUT_INVALID', '删除需要明确供应商、修订和确认');
466
+ const result = await application.run('writer', runtime => runtime.run('execute', async ({ dataRoot }) => {
467
+ const dependencies = providerDependencies(dataRoot, value.providerId);
468
+ if (dependencies.blockingTasks.length || dependencies.blockingRuns.length) fail('IRIS_PROVIDER_REFERENCED', '当前工作区仍有未完成或可恢复流程依赖此供应商,请先处理原任务', 409);
469
+ return executeConfigCommand(providerConfigPath, command, value, { registry, signal: controller.signal });
470
+ }), { signal: controller.signal });
471
+ providerMonitor?.snapshot(); return json(request, response, result);
472
+ }
473
+ const result = await executeConfigCommand(providerConfigPath, command, value, { registry, signal: controller.signal });
474
+ if (command === 'settings.enabled' || command === 'settings.save') providerMonitor?.snapshot();
475
+ return await json(request, response, result);
476
+ } catch (error) {
477
+ if (command === 'settings.workflow-check') {
478
+ controller.signal.throwIfAborted();
479
+ if (error instanceof ProviderContractError) fail('IRIS_COMFYUI_CHECK_UNAVAILABLE', '只读检查未完成,请核对 ComfyUI 服务、地址和认证;未提交生成', error.category === 'timeout' ? 504 : 502);
480
+ }
481
+ throw error;
482
+ } finally { response.off('close', aborted); providerManagementBusy = false; }
483
+ }
484
+ async function manage(request, response, url, caller) {
485
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以管理连接', 403);
486
+ if (!allowConnectionManagement) fail('IRIS_API_READ_ONLY', '此服务未开启连接管理', 405);
487
+ query(url);
488
+ if (['GET', 'HEAD'].includes(request.method)) {
489
+ if (url.pathname === '/api/v0/connection-management/connections') return json(request, response, managementFacts());
490
+ if (url.pathname === '/api/v0/connection-management/templates') return json(request, response, { contractVersion: 0, templates: managed.listTemplates(), presets: MCP_CONNECTION_PRESETS, formats: MCP_CONFIG_FORMATS });
491
+ if (url.pathname === '/api/v0/connection-management/catalog') return json(request, response, { contractVersion: 0, availableScopes, availableTools: availableTools(),
492
+ httpEnabled: allowMcp, templateExample: CONNECTION_TEMPLATE_EXAMPLE,
493
+ ...capabilityFacts(), defaults: { capabilities: DEFAULT_BRIDGE_CAPABILITIES } });
494
+ fail('IRIS_API_NOT_FOUND', '没有此连接管理接口', 404);
495
+ }
496
+ const value = await smallJson(request);
497
+ if (value?.capabilities !== undefined && checkedBridgeCapabilities(value.capabilities).some(id => !availableCapabilities().some(group => group.id === id))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此工作区尚未开启所选媒体功能', 403);
498
+ if (value?.pluginOperations !== undefined) {
499
+ const pins = checkedPluginSelection(value.pluginOperations);
500
+ const updating = /^\/api\/v0\/connection-management\/connections\/([a-f0-9-]{36})\/update$/.exec(url.pathname);
501
+ const retained = updating ? managed.list().find(item => item.connectionId === updating[1])?.pluginOperations || [] : [];
502
+ if (pins.some(pin => !hasPluginSelection(selectedOperations.map(item => item.dependency), pin) && !hasPluginSelection(retained, pin))) {
503
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只能选择此服务已启用的准确业务版本;旧连接可保留原依赖以读取历史记录', 403);
504
+ }
505
+ }
506
+ if (url.pathname === '/api/v0/connection-management/templates') {
507
+ const result = managed.importTemplate(value);
508
+ return json(request, response, { contractVersion: 0, ...result }, result.idempotentReplay ? 200 : 201);
509
+ }
510
+ if (url.pathname === '/api/v0/connection-management/connections') {
511
+ if (!Array.isArray(value?.scopes) || value.scopes.some(scope => !availableScopes.includes(scope))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '选择了当前服务未开启的操作权限', 403);
512
+ const result = managed.create(value);
513
+ // A brand-new grant has no cached bridge to invalidate on its first later rename.
514
+ changedAuthorizations.delete(result.connectionId);
515
+ return json(request, response, { contractVersion: 0, ...result }, result.idempotentReplay ? 200 : 201);
516
+ }
517
+ const action = /^\/api\/v0\/connection-management\/connections\/([a-f0-9-]{36})\/(update|revoke|delete|export|skill)$/.exec(url.pathname);
518
+ if (!action) fail('IRIS_API_NOT_FOUND', '没有此连接管理操作', 404);
519
+ const [, id, verb] = action;
520
+ if (verb === 'export') {
521
+ if (managed.resolveExport(value).transport === 'http' && !allowMcp) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此服务未开启 HTTP MCP', 403);
522
+ return json(request, response, { contractVersion: 0, ...managed.exportConfig(id, value) });
523
+ }
524
+ if (verb === 'skill') return json(request, response, { contractVersion: 0, ...managed.exportSkill(id, value) });
525
+ if (verb === 'update' && (!Array.isArray(value?.scopes) || value.scopes.some(scope => !availableScopes.includes(scope)))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '选择了当前服务未开启的操作权限', 403);
526
+ let result;
527
+ try { result = verb === 'update' ? managed.update(id, value) : verb === 'delete' ? managed.remove(id, value) : managed.revoke(id, value); }
528
+ finally { if (changedAuthorizations.delete(id)) await mcp?.invalidateConnection(id); }
529
+ return json(request, response, { contractVersion: 0, ...result });
129
530
  }
130
531
 
131
- async function image(artifactId) {
532
+ async function image(artifactId, kind = 'image') {
132
533
  // Use an operation-owned reader, with no writer lease or local export path.
133
534
  const reader = createCoreRuntime({ dataRoot: physicalRoot, mode: 'reader' });
134
535
  try {
135
536
  reader.start();
136
537
  return await reader.run('inspect', ({ dataRoot: physicalRoot }) => {
137
538
  const metadata = inspectCoreArtifactMetadata(physicalRoot, artifactId);
138
- if (!IMAGE_TYPES.has(metadata.mediaType)) fail('IRIS_API_MEDIA_UNSUPPORTED', '此接口只提供 PNG、JPEG 或 WebP 图片', 415);
139
- if (metadata.size > MAX_LOCAL_API_IMAGE_BYTES) fail('IRIS_API_MEDIA_TOO_LARGE', '图片超过只读接口的 64 MiB 上限', 413);
539
+ if (!(kind === 'image' ? IMAGE_TYPES.has(metadata.mediaType) : kind === 'audio' ? AUDIO_MEDIA_TYPES.has(metadata.mediaType) : metadata.mediaType === 'text/plain')) {
540
+ fail('IRIS_API_MEDIA_UNSUPPORTED', '此入口不支持该作品类型', 415);
541
+ }
542
+ if (metadata.size > (kind === 'text' ? 1024 * 1024 : MAX_LOCAL_API_IMAGE_BYTES)) fail('IRIS_API_MEDIA_TOO_LARGE', '作品超过此读取入口的大小上限', 413);
140
543
  const result = readCoreArtifactBytes(physicalRoot, artifactId);
141
544
  if (result.bytes.length > MAX_LOCAL_API_IMAGE_BYTES || result.bytes.length !== result.artifact.size
142
545
  || digest(result.bytes).toString('hex') !== result.artifact.digest.value) {
@@ -151,17 +554,39 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
151
554
  if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
152
555
  const catalog = loadProviderCatalog(providerConfigPath);
153
556
  let candidates;
154
- try { candidates = imageCandidatesFromCatalog(catalog); }
155
- catch (error) { if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error; candidates = []; }
557
+ try { candidates = imageCandidatesFromCatalog(catalog, '', registry); }
558
+ catch (error) {
559
+ if (error.code === 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID') {
560
+ return { contractVersion: 0, configured: true, models: [], total: 0, gap: 'protocol_unavailable' };
561
+ }
562
+ if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error;
563
+ candidates = [];
564
+ }
156
565
  const entries = candidates.map(route => ({ modelRef: route.modelRef, displayName: route.model,
157
- supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model) }));
566
+ ...(route.provider.mediaProtocol === 'comfyui' ? { comfyWorkflow: projectComfyWorkflow(route.provider.comfyWorkflow) } : {}),
567
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, registry) }));
568
+ return { contractVersion: 0, configured: true, models: entries, total: entries.length };
569
+ }
570
+
571
+ function audioModels(caller) {
572
+ if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
573
+ const catalog = loadProviderCatalog(providerConfigPath), entries = [];
574
+ const capabilities = caller.owner ? ['tts', 'transcribe'] : connections.selection(caller.connectionId, caller.runtimeInstanceId).capabilities;
575
+ for (const [capability, select] of [['tts', ttsCandidatesFromCatalog], ['transcribe', transcribeCandidatesFromCatalog]]) {
576
+ if (!capabilities.includes(capability)) continue;
577
+ let candidates;
578
+ try { candidates = select(catalog, '', registry); }
579
+ catch (error) { if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error; candidates = []; }
580
+ entries.push(...candidates.map(route => ({ capability, modelRef: route.modelRef, displayName: route.model })));
581
+ }
158
582
  return { contractVersion: 0, configured: true, models: entries, total: entries.length };
159
583
  }
160
584
 
161
585
  async function create(request, response, url) {
162
586
  query(url);
163
- if (!['/api/v0/images', '/api/v0/runs'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
164
- const isImage = url.pathname === '/api/v0/images';
587
+ if (!['/api/v0/images', '/api/v0/runs', '/api/v0/generations', '/api/v0/audio', '/api/v0/speeches', '/api/v0/transcriptions'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
588
+ const isImage = url.pathname === '/api/v0/images', isAudio = url.pathname === '/api/v0/audio', upload = isImage || isAudio;
589
+ if (!audioEnabled && ['/api/v0/audio', '/api/v0/speeches', '/api/v0/transcriptions'].includes(url.pathname)) fail('IRIS_FLOW_AUDIO_ROOT_REQUIRED', '请使用新的音频执行根;原根不迁移', 409);
165
590
  if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) {
166
591
  fail('IRIS_API_INPUT_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
167
592
  }
@@ -169,15 +594,15 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
169
594
  fail('IRIS_API_INPUT_INVALID', '正文需要单个 Content-Type,且不接受 Content-Encoding');
170
595
  }
171
596
  const mediaType = request.headers['content-type'];
172
- if (isImage ? !IMAGE_TYPES.has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
173
- fail('IRIS_API_MEDIA_UNSUPPORTED', '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
597
+ if (upload ? !(isImage ? IMAGE_TYPES : AUDIO_MEDIA_TYPES).has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
598
+ fail('IRIS_API_MEDIA_UNSUPPORTED', isAudio ? '请提供WAV/MP3/OGG/FLAC原字节与匹配的Content-Type' : '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
174
599
  }
175
- const limit = isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
600
+ const limit = isAudio ? MAX_AUDIO_INPUT_BYTES : isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
176
601
  if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) {
177
602
  fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
178
603
  }
179
- if (isImage && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
180
- if (isImage) activeUploads++;
604
+ if (upload && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
605
+ if (upload) activeUploads++;
181
606
  localWrites++;
182
607
  const controller = new AbortController();
183
608
  let prepared = false;
@@ -195,16 +620,18 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
195
620
  if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
196
621
  const bytes = Buffer.concat(chunks, size);
197
622
  let value;
198
- if (!isImage) {
623
+ if (!upload) {
199
624
  try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
200
625
  catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效的 UTF-8 JSON'); }
201
626
  }
202
- const result = await application.execute(isImage ? 'creation.image' : 'creation.flow', {
203
- key: request.headers['idempotency-key'], ...(isImage ? { bytes, mediaType } : { value })
627
+ const command = { '/api/v0/images': 'creation.image', '/api/v0/audio': 'creation.audio', '/api/v0/runs': 'creation.flow',
628
+ '/api/v0/generations': 'creation.generate', '/api/v0/speeches': 'creation.speech', '/api/v0/transcriptions': 'creation.transcribe' }[url.pathname];
629
+ const result = await application.execute(command, {
630
+ key: request.headers['idempotency-key'], ...(upload ? { bytes, mediaType } : { value })
204
631
  }, { signal: controller.signal, onPrepared: () => { prepared = true; } });
205
632
  const body = { contractVersion: 0, idempotentReplay: result.idempotentReplay,
206
- ...(isImage ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
207
- size: result.artifact.size, width: result.artifact.metadata.width, height: result.artifact.metadata.height } }
633
+ ...(upload ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
634
+ size: result.artifact.size, ...(isImage ? { width: result.artifact.metadata.width, height: result.artifact.metadata.height } : {}) } }
208
635
  : { flow: projectLocalApiFlow(result.flow) }) };
209
636
  return await json(request, response, body, result.idempotentReplay ? 200 : 201);
210
637
  } catch (error) {
@@ -212,15 +639,90 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
212
639
  throw error;
213
640
  } finally {
214
641
  response.off('close', disconnect); localWrites--;
215
- if (isImage) activeUploads--;
642
+ if (upload) activeUploads--;
216
643
  }
217
644
  }
218
645
 
219
646
  async function route(request, response) {
220
647
  if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
221
- authorize(request);
222
- if (!['GET', 'HEAD'].includes(request.method) && !(allowLocalWrites && request.method === 'POST')) {
223
- fail('IRIS_API_READ_ONLY', allowLocalWrites ? '仅支持 GET/HEAD 与明确的本地创建 POST' : '当前接口仅支持只读 GET/HEAD', 405);
648
+ const passwordPath = browserAuth && /^\/api\/v0\/workbench\/(auth|setup|password-login|logout|setup-link|password-reset)$/.exec(request.url.split('?')[0]);
649
+ if (passwordPath) {
650
+ authorizeOrigin(request); query(new URL(request.url, origin));
651
+ const action = passwordPath[1];
652
+ if (action === 'auth') {
653
+ if (!['GET', 'HEAD'].includes(request.method)) throw Object.assign(new LocalApiError('IRIS_API_METHOD_DENIED', '登录状态仅支持 GET/HEAD', 405), { allow: 'GET, HEAD' });
654
+ if (request.headers['transfer-encoding'] !== undefined || request.headers['content-length'] && request.headers['content-length'] !== '0') fail('IRIS_API_INPUT_INVALID', '登录状态请求不接受正文');
655
+ return json(request, response, { contractVersion: 0, ...browserAuth.info() });
656
+ }
657
+ if (request.method !== 'POST') fail('IRIS_API_METHOD_DENIED', '登录操作仅支持 POST', 405);
658
+ const control = ['setup-link', 'password-reset'].includes(action);
659
+ if (control) {
660
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
661
+ if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)
662
+ || request.headers['iris-connection-id'] !== undefined || request.headers['iris-runtime-id'] !== undefined) {
663
+ fail('IRIS_API_AUTH_REQUIRED', '此操作需要本机工作台控制权限,请通过 CLI 执行', 401);
664
+ }
665
+ } else if (request.headers.origin !== origin) fail('IRIS_API_ORIGIN_DENIED', '请在当前工作台页面登录或退出', 403);
666
+ const value = await smallJson(request, 1024);
667
+ const keys = action === 'setup' ? ['ticket', 'password', 'remember'] : action === 'password-login' ? ['password', 'remember'] : [];
668
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) {
669
+ fail('IRIS_API_INPUT_INVALID', '登录请求字段无效');
670
+ }
671
+ if (action === 'setup') return json(request, response, { contractVersion: 0, ...await browserAuth.setup(value) });
672
+ if (action === 'password-login') return json(request, response, { contractVersion: 0, ...await browserAuth.login(value) });
673
+ if (action === 'logout') {
674
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
675
+ if (!match) fail('IRIS_API_AUTH_REQUIRED', '需要当前浏览器会话', 401);
676
+ return json(request, response, { contractVersion: 0, ...browserAuth.logout(match[1]) });
677
+ }
678
+ if (action === 'password-reset') await browserAuth.reset();
679
+ return json(request, response, { contractVersion: 0, setupUrl: browserAuth.setupLink(origin) });
680
+ }
681
+ const loginPath = serveWorkbench && /^\/api\/v0\/workbench\/(login|login-link)$/.exec(request.url.split('?')[0]);
682
+ if (loginPath) {
683
+ authorizeOrigin(request); query(new URL(request.url, origin));
684
+ if (browserAuth) fail('IRIS_WEB_PASSWORD_REQUIRED', '请在工作台普通地址用访问口令登录;首次设置可运行 web login --data-root', 409);
685
+ if (request.method !== 'POST') fail('IRIS_API_METHOD_DENIED', '登录入口仅接受 POST', 405);
686
+ if (loginPath[1] === 'login') {
687
+ if (request.headers.origin !== origin) fail('IRIS_API_ORIGIN_DENIED', '请在本机工作台打开登录链接', 403);
688
+ } else if (!authorize(request).owner) fail('IRIS_API_AUTH_REQUIRED', '只有工作台所有者可以获取登录链接', 403);
689
+ const value = await smallJson(request, 256);
690
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== (loginPath[1] === 'login' ? 1 : 0)
691
+ || loginPath[1] === 'login' && (typeof value.ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.ticket))) {
692
+ fail('IRIS_API_INPUT_INVALID', '登录请求无效');
693
+ }
694
+ return json(request, response, { contractVersion: 0, ...(loginPath[1] === 'login' ? redeemLoginLink(value.ticket) : issueLoginLink()) });
695
+ }
696
+ if (allowMcp && request.url.split('?')[0] === '/mcp') {
697
+ authorizeOrigin(request); query(new URL(request.url, origin));
698
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
699
+ if (!match) fail('IRIS_CONNECTION_AUTH_REQUIRED', '需要有效的 MCP 连接凭据', 401);
700
+ if (request.headers['iris-connection-id'] !== undefined || request.headers['iris-runtime-id'] !== undefined) fail('IRIS_CONNECTION_INPUT_INVALID', 'MCP 仅使用标准连接头');
701
+ return mcp.handle(request, response, match[1]);
702
+ }
703
+ if (serveWorkbench && (panel.has(request.url.split('?')[0]) || pluginPanel?.has(request.url.split('?')[0]))) {
704
+ // The shell contains no private data. API and image requests still require Bearer auth.
705
+ authorizeOrigin(request, request.url.split('?')[0] === '/' && request.method === 'GET');
706
+ if (!['GET', 'HEAD'].includes(request.method)) fail('IRIS_API_READ_ONLY', '页面资源仅支持 GET/HEAD', 405);
707
+ if (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0')) {
708
+ fail('IRIS_API_INPUT_INVALID', '页面请求不接受正文');
709
+ }
710
+ const url = new URL(request.url, origin);
711
+ query(url);
712
+ const asset = panel.has(url.pathname) ? await panel.read(url.pathname) : await pluginPanel.read(url.pathname);
713
+ if (!asset) fail('IRIS_API_UNAVAILABLE', '插件资源已关闭', 503);
714
+ const bytes = browserAuth && url.pathname === '/' && panel.identity.id === 'iris.default-panel'
715
+ ? Buffer.from(asset.bytes.toString('utf8').replace('data-auth="token"', 'data-auth="password"')) : asset.bytes;
716
+ return send(request, response, 200, bytes, { 'Content-Type': asset.mediaType,
717
+ 'Content-Security-Policy': LOCAL_WEB_CSP, 'Cross-Origin-Resource-Policy': 'same-origin' });
718
+ }
719
+ const caller = authorize(request);
720
+ const connectionPost = connections && /^\/api\/v0\/connections\/[a-f0-9-]{36}\/(?:handshake|disconnect|client-info)$/.test(request.url.split('?')[0]);
721
+ const managementPost = allowConnectionManagement && request.url.split('?')[0].startsWith('/api/v0/connection-management/');
722
+ const providerPost = allowProviderManagement && request.url.split('?')[0].startsWith('/api/v0/provider-management/');
723
+ const pluginPost = pluginExecution && (request.url.split('?')[0] === '/api/v0/plugin-operations' || /^\/api\/v0\/operations\/op_[a-f0-9]{64}\/cancel$/.test(request.url.split('?')[0]));
724
+ if (!['GET', 'HEAD'].includes(request.method) && !((allowLocalWrites || allowSelection || connectionPost || managementPost || providerPost || pluginPost) && request.method === 'POST')) {
725
+ fail('IRIS_API_READ_ONLY', allowLocalWrites || allowSelection ? '仅支持 GET/HEAD 与明确开启的 POST 操作' : '当前接口仅支持只读 GET/HEAD', 405);
224
726
  }
225
727
  if (request.method !== 'POST' && (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))) {
226
728
  fail('IRIS_API_INPUT_INVALID', '只读请求不接受正文');
@@ -228,48 +730,162 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
228
730
  if (!request.url.startsWith('/') || request.url.startsWith('//')) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
229
731
  const url = new URL(request.url, origin);
230
732
  if (url.pathname !== request.url.split('?')[0]) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
231
- if (request.method === 'POST') return create(request, response, url);
733
+ if (request.method === 'POST') return specialPost(request, response, url, caller);
734
+ if (url.pathname.startsWith('/api/v0/connection-management')) return manage(request, response, url, caller);
735
+ if (url.pathname.startsWith('/api/v0/provider-management')) return manageProviders(request, response, url, caller);
736
+ const callerScopes = scope(caller, 'read');
232
737
  const matchRun = /^\/api\/v0\/runs\/([^/]+)(\/download)?$/.exec(url.pathname);
233
738
  const matchImage = /^\/api\/v0\/artifacts\/([^/]+)\/content$/.exec(url.pathname);
234
- query(url, url.pathname === '/api/v0/runs' ? ['limit', 'cursor'] : []);
235
- if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1])) {
739
+ const matchAudio = audioEnabled && /^\/api\/v0\/artifacts\/([^/]+)\/(audio|text)$/.exec(url.pathname);
740
+ const operation = (allowExecution || pluginExecution) && /^\/api\/v0\/operations\/(op_[a-f0-9]{64})$/.exec(url.pathname);
741
+ const runOperations = allowExecution && /^\/api\/v0\/runs\/(run_[a-f0-9]{24})\/operations$/.exec(url.pathname);
742
+ const operationLookup = (allowExecution || pluginExecution) && url.pathname === '/api/v0/operations';
743
+ const pluginCatalog = pluginExecution && url.pathname === '/api/v0/plugin-operations/catalog';
744
+ const pluginHistory = pluginExecution && url.pathname === '/api/v0/plugin-operations';
745
+ const creationLookup = url.pathname === '/api/v0/creation-requests';
746
+ if (creationLookup && !caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以定位原创建请求', 403);
747
+ query(url, pluginCatalog ? ['limit', 'offset'] : pluginHistory ? ['limit', 'cursor'] : url.pathname === '/api/v0/runs' ? ['limit', 'cursor', 'selection', 'kind'] : runOperations ? ['limit', 'cursor']
748
+ : creationLookup ? ['kind', 'key'] : operationLookup ? ['key'] : []);
749
+ if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1]) || matchAudio && !ARTIFACT.test(matchAudio[1])) {
236
750
  fail('IRIS_API_INPUT_INVALID', '流程或图片 ID 格式无效');
237
751
  }
752
+ const readTool = pluginCatalog ? 'iris_core_plugin_catalog' : pluginHistory ? 'iris_core_operation' : operation ? 'iris_core_operation' : runOperations ? 'iris_core_operation' : operationLookup ? 'iris_core_lookup'
753
+ : matchAudio ? matchAudio[2] === 'audio' ? 'iris_core_audio' : 'iris_core_transcript'
754
+ : matchImage ? 'iris_core_image' : matchRun ? matchRun[2] ? null : 'iris_core_run'
755
+ : ({ '/api/v0/models': 'iris_core_models', '/api/v0/audio-models': 'iris_core_audio_models', '/api/v0/runs': 'iris_core_runs', '/api/v0/connections': 'iris_core_connections', '/api/v0/adapters': 'iris_core_connection' })[url.pathname];
756
+ if (readTool) scope(caller, 'read', readTool);
757
+ if (matchRun?.[2] && !caller.owner && !connections.selection(caller.connectionId, caller.runtimeInstanceId).tools
758
+ .some(name => ['iris_core_image', 'iris_core_audio', 'iris_core_transcript'].includes(name))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '未选择作品交付工具', 403);
238
759
  if (activeReads >= 2) fail('IRIS_API_BUSY', '读取繁忙,请稍后再试', 429);
239
760
  activeReads++;
240
761
  try {
762
+ if (pluginCatalog) {
763
+ const pins = caller.owner ? selectedOperations.map(item => item.dependency) : connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [];
764
+ const limit = Number(url.searchParams.get('limit') ?? 20), offset = Number(url.searchParams.get('offset') ?? 0);
765
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30 || !Number.isSafeInteger(offset) || offset < 0) fail('IRIS_API_INPUT_INVALID', '作品分页无效');
766
+ const catalog = capabilityFacts();
767
+ const entries = pins.map(pin => { const found = catalog.contributions.operations.find(item => hasPluginSelection([pin], item.dependency));
768
+ return catalog.state === 'active' && found && allowedPluginIds.includes(found.id) ? { ...found, available: true } : { id: pin.contributionId, dependency: pin, available: false }; });
769
+ const artifacts = entries.some(item => item.available && item.artifactInputs?.length) ? await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => {
770
+ const value = listCoreArtifacts(dataRoot, { offset, limit });
771
+ return { ...value, nextOffset: offset + value.artifacts.length < value.total ? offset + value.artifacts.length : null,
772
+ artifacts: value.artifacts.filter(isPluginSourceImage)
773
+ .map(item => ({ id: item.id, mediaType: item.mediaType, size: item.size, createdAt: item.createdAt })) };
774
+ })) : { artifacts: [], total: 0 };
775
+ return json(request, response, { contractVersion: 0, operations: entries, ...artifacts });
776
+ }
777
+ if (pluginHistory) {
778
+ const pins = caller.owner ? null : connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [];
779
+ const limit = Number(url.searchParams.get('limit') ?? 20), cursor = url.searchParams.get('cursor');
780
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30 || cursor !== null && !/^op_[a-f0-9]{64}$/.test(cursor)) fail('IRIS_API_INPUT_INVALID', '业务记录分页无效');
781
+ const facts = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => {
782
+ const records = readAllOperations(dataRoot).filter(item => item.action === 'plugin.execute' && (!pins || hasPluginSelection(pins, item.dependency)))
783
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
784
+ const offset = cursor === null ? 0 : records.findIndex(item => item.id === cursor) + 1;
785
+ if (cursor !== null && offset === 0) fail('IRIS_API_INPUT_INVALID', '游标不属于此连接的业务记录');
786
+ const page = records.slice(offset, offset + limit);
787
+ return { operations: page.map(projectOperation), total: records.length, nextCursor: offset + page.length < records.length ? page.at(-1).id : null };
788
+ }));
789
+ return json(request, response, { contractVersion: 0, ...facts });
790
+ }
791
+ if (url.pathname === '/api/v0/capabilities') {
792
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以查看完整贡献目录', 403);
793
+ return await json(request, response, capabilityFacts());
794
+ }
241
795
  if (url.pathname === '/api/v0/session') {
242
- return await json(request, response, { contractVersion: 0, instanceId, readOnly: !allowLocalWrites,
243
- operations: ['models.list', 'runs.list', 'runs.inspect', 'artifacts.content', 'runs.download',
244
- ...(allowLocalWrites ? ['images.create', 'runs.create'] : [])],
796
+ const selection = caller.owner ? null : connections.selection(caller.connectionId, caller.runtimeInstanceId);
797
+ const identity = caller.owner ? await application.execute('creation.root', {}) : null;
798
+ const canSelect = Boolean(caller.owner && allowSelection);
799
+ const mediaReadOnly = !callerScopes.includes('create') && !(allowExecution && callerScopes.includes('execute')) && !canSelect;
800
+ return await json(request, response, { contractVersion: 0, instanceId, readOnly: mediaReadOnly,
801
+ ...(identity ? { rootId: identity.rootId } : {}),
802
+ connectionManagement: Boolean(caller.owner && allowConnectionManagement), workbenchMediaReadOnly: serveWorkbench && mediaReadOnly,
803
+ providerManagement: Boolean(caller.owner && allowProviderManagement), ...(serveWorkbench && caller.owner ? { panel: panel.identity } : {}),
804
+ ...(pluginPanel && caller.owner ? { panelContributions: pluginPanel.manifest } : {}),
805
+ ...(pluginExecution ? { pluginExecution: true, pluginOperations: caller.owner ? selectedOperations.map(item => item.dependency) : selection?.pluginOperations || [] } : {}),
806
+ ...(audioEnabled ? { audio: true } : {}),
807
+ operations: projectCoreSessionOperations({ scopes: callerScopes, ...(selection ? { tools: selection.tools } : {}),
808
+ imageGeneration: imageGenerationEnabled, audio: audioEnabled, execution: allowExecution, pluginExecution,
809
+ creationLookup: Boolean(identity?.rootId), selection: canSelect }),
810
+ ...(selection || {}),
811
+ ...(allowExecution || pluginExecution ? { executionEnabled: runner.snapshot().executionEnabled } : {}),
245
812
  limits: { runPageSize: 30, imageBytes: MAX_LOCAL_API_IMAGE_BYTES, activeReads: 2,
246
813
  ...(allowLocalWrites ? { uploadBytes: MAX_IMPORT_IMAGE_BYTES, jsonBytes: 64 * 1024, activeUploads: 1, localWrites: 9 } : {}) } });
247
814
  }
815
+ if (connections && ['/api/v0/connections', '/api/v0/adapters'].includes(url.pathname)) {
816
+ const facts = connections.list(caller.owner ? undefined : caller.connectionId);
817
+ return await json(request, response, url.pathname.endsWith('/adapters') ? { contractVersion: 0, adapters: facts.adapters } : facts);
818
+ }
819
+ if (operation) {
820
+ const result = await application.execute('operation.inspect', { id: operation[1] });
821
+ await requireOperationCapability(caller, result.operation);
822
+ return await json(request, response, result);
823
+ }
824
+ if (creationLookup) {
825
+ const kind = url.searchParams.get('kind'), key = url.searchParams.get('key');
826
+ if (!CREATION_KINDS.includes(kind) || !KEY.test(key || '')) fail('IRIS_API_INPUT_INVALID', '需要明确的创建类型与原 UUIDv4 Key');
827
+ return await json(request, response, await application.execute('creation.lookup', { kind, key }));
828
+ }
829
+ if (operationLookup) {
830
+ if (!KEY.test(url.searchParams.get('key') || '')) fail('IRIS_API_INPUT_INVALID', '需要原 Operation Key');
831
+ const result = await application.execute('operation.lookup', { key: url.searchParams.get('key') });
832
+ await requireOperationCapability(caller, result.operation);
833
+ return await json(request, response, result);
834
+ }
835
+ if (runOperations) {
836
+ await requireFlowCapability(caller, runOperations[1]);
837
+ const limit = url.searchParams.get('limit'), cursor = url.searchParams.get('cursor');
838
+ if (limit !== null && !/^(?:[1-9]|[12][0-9]|30)$/.test(limit)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30');
839
+ return await json(request, response, await application.execute('operation.list', { runId: runOperations[1],
840
+ ...(limit !== null ? { limit: Number(limit) } : {}), ...(cursor !== null ? { cursor } : {}) }));
841
+ }
248
842
  if (url.pathname === '/api/v0/models') return await json(request, response, models());
843
+ if (url.pathname === '/api/v0/audio-models' && audioEnabled) return await json(request, response, audioModels(caller));
249
844
  if (url.pathname === '/api/v0/runs') {
250
845
  const limitText = url.searchParams.get('limit') ?? '20';
251
846
  if (!/^(?:[1-9]|[12][0-9]|30)$/.test(limitText)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30 的整数');
252
847
  const cursor = url.searchParams.get('cursor');
253
848
  if (cursor !== null && !RUN.test(cursor)) fail('IRIS_API_INPUT_INVALID', '游标无效');
254
- const result = await application.execute('flow.list', {});
255
- const ordered = [...result.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
849
+ const selection = url.searchParams.get('selection') ?? 'all';
850
+ if (!['all', 'selected', 'unselected'].includes(selection)) fail('IRIS_API_INPUT_INVALID', '选择筛选无效');
851
+ const kind = url.searchParams.get('kind') ?? 'all';
852
+ if (!['all', 'image', 'speech', 'transcribe'].includes(kind)) fail('IRIS_API_INPUT_INVALID', '作品类别无效');
853
+ const records = (await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => listFlowRuns(dataRoot))))
854
+ .filter(run => allowsRun(caller, run) && (kind === 'all' || kind === 'image' && !isAudioRecipe(run.recipe)
855
+ || kind === 'speech' && run.recipe === 'speech/v0' || kind === 'transcribe' && run.recipe === 'transcribe/v0'));
856
+ const selected = records.filter(run => Boolean(run.selection)).length;
857
+ const counts = { all: records.length, selected, unselected: records.length - selected };
858
+ const ordered = records.filter(run => selection === 'all' || Boolean(run.selection) === (selection === 'selected'))
859
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
256
860
  const index = cursor === null ? 0 : ordered.findIndex(run => run.id === cursor) + 1;
257
861
  if (cursor !== null && index === 0) fail('IRIS_API_INPUT_INVALID', '游标已失效,请重新读取列表');
258
862
  const page = ordered.slice(index, index + Number(limitText));
259
- return await json(request, response, { contractVersion: 0, total: ordered.length,
863
+ return await json(request, response, { contractVersion: 0, total: ordered.length, counts,
260
864
  runs: page.map(projectLocalApiRunSummary), nextCursor: index + page.length < ordered.length ? page.at(-1).id : null });
261
865
  }
262
- let artifactId;
866
+ let artifactId, mediaKind = matchAudio ? matchAudio[2] : 'image';
263
867
  if (matchRun) {
868
+ await requireFlowCapability(caller, matchRun[1]);
264
869
  const view = projectLocalApiFlow(await application.execute('flow.inspect', { run_id: matchRun[1] }));
265
870
  if (!matchRun[2]) return await json(request, response, view);
266
- if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
267
- artifactId = view.selection.artifactId;
871
+ const audio = isAudioRecipe(view.recipe);
872
+ if (audio) {
873
+ mediaKind = view.recipe === 'speech/v0' ? 'audio' : 'text';
874
+ scope(caller, 'read', mediaKind === 'audio' ? 'iris_core_audio' : 'iris_core_transcript');
875
+ if (!view.output) fail('IRIS_API_SELECTION_REQUIRED', '请先接回原任务结果', 409);
876
+ artifactId = view.output.artifactId;
877
+ } else {
878
+ scope(caller, 'read', 'iris_core_image');
879
+ if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
880
+ artifactId = view.selection.artifactId;
881
+ }
268
882
  } else if (matchImage) artifactId = matchImage[1];
883
+ else if (matchAudio) artifactId = matchAudio[1];
269
884
  else fail('IRIS_API_NOT_FOUND', '没有此只读接口', 404);
270
- const result = await image(artifactId);
885
+ await requireArtifactCapability(caller, artifactId);
886
+ const result = await image(artifactId, mediaKind);
271
887
  return await send(request, response, 200, result.bytes, { 'Content-Type': result.artifact.mediaType,
272
- ...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType)}"` } : {}) });
888
+ ...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType) || AUDIO_TYPES[result.artifact.mediaType] || 'txt'}"` } : {}) });
273
889
  } finally { activeReads--; }
274
890
  }
275
891
 
@@ -282,7 +898,8 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
282
898
  await send(request, response, safe.status, Buffer.from(JSON.stringify({ contractVersion: 0,
283
899
  error: { code: safe.code, message: safe.message, requestId } })), {
284
900
  Connection: 'close', ...(safe.status === 401 ? { 'WWW-Authenticate': 'Bearer realm="iris-local"' } : {}),
285
- ...(safe.status === 405 ? { Allow: allowLocalWrites ? 'GET, HEAD, POST' : 'GET, HEAD' } : {}) });
901
+ ...(safe.status === 405 ? { Allow: safe.allow || (safe.code === 'IRIS_API_METHOD_DENIED' ? 'POST'
902
+ : allowLocalWrites || allowSelection || allowConnectionManagement || allowProviderManagement ? 'GET, HEAD, POST' : 'GET, HEAD') } : {}) });
286
903
  }
287
904
  }
288
905
 
@@ -293,28 +910,109 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
293
910
  await completion;
294
911
  }
295
912
 
913
+ async function cleanupResources() {
914
+ const failures = [];
915
+ for (const [stage, close] of [
916
+ ['provider-monitor', () => providerMonitor?.dispose()],
917
+ ['http', closeServer], ['mcp', () => mcp?.dispose()], ['runner', () => runnerStarted ? runner.dispose() : undefined],
918
+ ['application', () => application?.dispose()], ['requests', () => Promise.all([...jobs])],
919
+ ['connections-store', () => managed?.dispose()], ['connections', () => connections?.dispose()],
920
+ ['workbench-auth', () => browserAuth?.dispose()],
921
+ ['providers', () => suppliedRegistry ? undefined : registry?.dispose()]
922
+ ]) {
923
+ try { await close(); } catch (error) { failures.push({ stage, error }); }
924
+ }
925
+ if (failures.length) {
926
+ cleanupFailed = true;
927
+ throw Object.assign(new AggregateError(failures.map(item => item.error), '本地服务释放失败:' + failures.map(item => item.stage).join(', ')),
928
+ { code: 'IRIS_API_CLEANUP_FAILED', stages: failures.map(item => item.stage) });
929
+ }
930
+ }
931
+
296
932
  function start({ port = 0 } = {}) {
297
933
  if (state !== 'new') return Promise.reject(new LocalApiError('IRIS_API_LIFECYCLE', '服务只能显式启动一次', 409));
298
934
  if (!Number.isInteger(port) || port < 0 || port > 65535) return Promise.reject(new LocalApiError('IRIS_API_INPUT_INVALID', '端口必须是 0–65535 的整数'));
299
935
  state = 'starting';
300
936
  const result = (async () => {
301
937
  try {
938
+ if (pluginRegistry && pluginRegistry.snapshot().state !== 'active') fail('IRIS_API_CONFIG_INVALID', '插件注册实例须处于活动状态');
939
+ registry ||= await createProviderRegistry();
940
+ if (panelManifestPath) panel = loadPanelExtension(panelManifestPath);
941
+ if (panelContributions !== undefined) pluginPanel = createPluginPanelAssembly(pluginRegistry, panelContributions);
942
+ if (allowProviderManagement) loadProviderCatalog(providerConfigPath);
302
943
  const reader = createCoreRuntime({ dataRoot: suppliedRoot, mode: 'reader' });
303
944
  try {
304
945
  reader.start();
305
946
  physicalRoot = await reader.run('inspect', ({ dataRoot: root }) => {
306
947
  inspectFlowRoot(root);
307
- if (allowLocalWrites) inspectCreationRoot(root);
948
+ const version = inspectCreationRoot(root, { allowAbsent: !allowLocalWrites })?.schemaVersion;
949
+ imageGenerationEnabled = [2, 3].includes(version); audioEnabled = version === 3;
308
950
  return root;
309
951
  });
310
952
  } finally { await reader.dispose(); }
311
953
  if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
312
- application = createApplicationRuntime({ dataRoot: physicalRoot,
313
- ...(allowLocalWrites && providerConfigPath ? { resolveImageCandidates: ({ modelRef }) =>
314
- imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef).map(route => ({
954
+ if (workbenchAuth) { browserAuth = createWorkbenchAuth({ ...workbenchAuth, dataRoot: physicalRoot }); browserAuth.start(); }
955
+ let ports = {};
956
+ if (allowExecution) {
957
+ if (!executionPorts) loadProviderCatalog(providerConfigPath); // Static validation only; no discovery or model call.
958
+ ports = executionPorts || configuredImagePorts(providerConfigPath, registry);
959
+ }
960
+ let grants = [];
961
+ if (connectionsFile) {
962
+ const file = fs.realpathSync.native(connectionsFile), relative = path.relative(physicalRoot, file);
963
+ if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) fail('IRIS_API_CONFIG_INVALID', '授权文件须放在数据根之外');
964
+ grants = readConnectionGrants(connectionsFile); const marker = inspectExecutionRoot(physicalRoot);
965
+ if (grants.some(grant => grant.rootId !== marker.rootId)) fail('IRIS_API_CONFIG_INVALID', '连接授权属于另一数据空间');
966
+ }
967
+ if (connectionsFile || allowConnectionManagement) connections = createConnectionRegistry(grants, availableScopes,
968
+ { availableCapabilities: availableCapabilities().map(group => group.id), availableTools: availableTools(), onClientInfo: (id, contact) => managed?.observe(id, contact) });
969
+ if (allowConnectionManagement) {
970
+ const marker = inspectExecutionRoot(physicalRoot);
971
+ managed = createManagedConnectionStore({ directory: connectionStore, dataRoot: physicalRoot, rootId: marker.rootId,
972
+ getOrigin: () => origin, availableScopes, availableTools: availableTools(), beforeCreate: id => {
973
+ const active = connections.list().connections;
974
+ if (active.some(item => item.connectionId === id)) fail('IRIS_CONNECTION_CONFIG_CHANGED', '连接身份与现有授权冲突', 409);
975
+ if (active.length >= 32) fail('IRIS_CONNECTION_BUSY', '活动连接已达 32 项,请先撤销不用的连接', 429);
976
+ }, onChange: (grant, id) => {
977
+ if (!grant) { connections.revoke(id); changedAuthorizations.add(id); }
978
+ else if (connections.setGrant(grant).authorizationChanged) changedAuthorizations.add(id);
979
+ } });
980
+ const dynamic = await managed.start();
981
+ if (grants.length + dynamic.length > 32 || dynamic.some(value => grants.some(item => item.connectionId === value.connectionId || item.accessToken === value.accessToken))) {
982
+ fail('IRIS_CONNECTION_CONFIG_CHANGED', '静态与面板授权冲突或超过容量', 409);
983
+ }
984
+ for (const grant of dynamic) connections.setGrant(grant);
985
+ }
986
+ application = createApplicationRuntime({ dataRoot: physicalRoot, ...ports,
987
+ ...(!allowExecution && allowLocalWrites && providerConfigPath ? { resolveAudioCandidates: ({ capability, modelRef }) => {
988
+ const select = capability === 'tts' ? ttsCandidatesFromCatalog : transcribeCandidatesFromCatalog;
989
+ return select(loadProviderCatalog(providerConfigPath), modelRef, registry).map(route => ({
315
990
  adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol }, model: route.modelRef,
316
- providerBinding: providerTaskBinding(route.provider), selectionReason: 'explicit'
317
- })) } : {}) });
991
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
992
+ }));
993
+ }, resolveImageCandidates: ({ modelRef, input }) =>
994
+ imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef, registry).map(route => {
995
+ if (input?.workflow_parameters !== undefined) {
996
+ if (route.provider.mediaProtocol !== 'comfyui') throw Object.assign(new TypeError('工作流参数仅用于 ComfyUI 模板'), { code: 'IRIS_COMFYUI_PARAMETERS_INVALID' });
997
+ compileComfyWorkflow(route.provider.comfyWorkflow, input);
998
+ }
999
+ return {
1000
+ adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol }, model: route.modelRef,
1001
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, registry),
1002
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
1003
+ };
1004
+ }) } : {}) });
1005
+ if (allowExecution || pluginExecution) {
1006
+ runner = createOperationRunner({ dataRoot: physicalRoot, application, ...ports, onExecutionError: reportUnexpected, pluginRegistry, allowedOperations: allowedPluginIds });
1007
+ await runner.start(); runnerStarted = true;
1008
+ }
1009
+ if (allowMcp) {
1010
+ const { createMcpHttpGateway } = await import('./mcp-http.js');
1011
+ const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1012
+ mcp = createMcpHttpGateway({ getOrigin: () => origin, authenticate: token => connections.identifyMcp(token),
1013
+ availableScopes, version: manifest.version });
1014
+ await mcp.start();
1015
+ }
318
1016
  server = http.createServer({ maxHeaderSize: 8192, headersTimeout: 10000, requestTimeout: 15000 }, (request, response) => {
319
1017
  const job = handle(request, response).catch(error => {
320
1018
  reportUnexpected(error, crypto.randomUUID()); response.destroy();
@@ -334,12 +1032,15 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
334
1032
  const accessToken = crypto.randomBytes(32).toString('base64url');
335
1033
  tokenDigest = digest(accessToken);
336
1034
  if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
1035
+ if (browserAuth) browserAuth.bindControl({ origin, accessToken });
337
1036
  state = 'open';
338
- return Object.freeze({ origin, accessToken });
1037
+ return Object.freeze({ origin, accessToken,
1038
+ ...(browserAuth ? { authentication: browserAuth.info(), loginUrl: browserAuth.info().configured ? origin : browserAuth.setupLink(origin) }
1039
+ : serveWorkbench ? issueLoginLink() : {}), ...(allowMcp ? { mcpEndpoint: origin + '/mcp' } : {}) });
339
1040
  } catch (error) {
340
- await closeServer(); await application?.dispose();
341
- state = closeRequested ? 'closing' : 'closed';
342
- if (!closeRequested) resolveClosed(snapshot());
1041
+ try { await cleanupResources(); }
1042
+ catch (cleanup) { throw Object.assign(new AggregateError([error, cleanup], '本地服务启动与释放均失败'), { code: 'IRIS_API_STARTUP_CLEANUP_FAILED' }); }
1043
+ finally { state = closeRequested ? 'closing' : 'closed'; if (!closeRequested) resolveClosed(snapshot()); }
343
1044
  throw error;
344
1045
  }
345
1046
  })();
@@ -350,11 +1051,11 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
350
1051
 
351
1052
  function dispose() {
352
1053
  if (disposal) return disposal;
353
- closeRequested = true; state = 'closing'; tokenDigest?.fill(0);
1054
+ closeRequested = true; state = 'closing'; tokenDigest?.fill(0); loginTicket = null; browserSessions.clear();
354
1055
  disposal = (async () => {
355
1056
  if (startPromise) await startPromise.catch(() => {}); // start's caller retains the original failure.
356
- await closeServer(); await application?.dispose(); await Promise.all([...jobs]);
357
- state = 'closed'; resolveClosed(snapshot());
1057
+ try { await cleanupResources(); }
1058
+ finally { state = 'closed'; resolveClosed(snapshot()); }
358
1059
  return snapshot();
359
1060
  })();
360
1061
  return disposal;
@@ -367,8 +1068,9 @@ export function writeLocalApiTokenFile(file, token, dataRoot) {
367
1068
  if (typeof file !== 'string' || !path.isAbsolute(file)) fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须是新的绝对路径');
368
1069
  let fd;
369
1070
  try {
370
- const root = fs.realpathSync(dataRoot), parent = fs.realpathSync(path.dirname(file));
371
- const destination = path.join(parent, path.basename(file)), relative = path.relative(root, destination);
1071
+ const root = fs.realpathSync.native(dataRoot), parent = fs.realpathSync(path.dirname(file));
1072
+ const destination = path.join(parent, path.basename(file));
1073
+ const relative = path.relative(root, path.join(fs.realpathSync.native(parent), path.basename(file)));
372
1074
  if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
373
1075
  fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须放在 Core 数据根之外');
374
1076
  }