@xmanrui/dsh-im 4.21.1 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.21.1",
3
+ "version": "4.22.0",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -114,6 +114,7 @@
114
114
  "src",
115
115
  "PROACTIVE_DELIVERY.md",
116
116
  "PROACTIVE_DELIVERY.en.md",
117
+ "docs/client-integration.md",
117
118
  "LICENSE",
118
119
  "README.md",
119
120
  "README.en.md",
@@ -826,6 +826,7 @@ export function FeishuSettingsTab({ rpcCall }) {
826
826
  const [pageBusy, setPageBusy] = React.useState(false);
827
827
  const [provisionBusy, setProvisionBusy] = React.useState(false);
828
828
  const [credentialOpen, setCredentialOpen] = React.useState(false);
829
+ const [credentialDomain, setCredentialDomain] = React.useState("feishu");
829
830
  const [credentialBusy, setCredentialBusy] = React.useState(false);
830
831
  const [credentialError, setCredentialError] = React.useState(null);
831
832
  const [busyByBot, setBusyByBot] = React.useState({});
@@ -1041,13 +1042,13 @@ export function FeishuSettingsTab({ rpcCall }) {
1041
1042
  try {
1042
1043
  const snapshot = normalizeBotsSnapshot(await invoke(
1043
1044
  FEISHU_ENDPOINTS.bindCredentials,
1044
- { appId: identity, appSecret: secret },
1045
+ { appId: identity, appSecret: secret, domain: credentialDomain },
1045
1046
  ));
1046
1047
  if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
1047
1048
  mergeSnapshot(snapshot);
1048
1049
  }
1049
1050
  setCredentialOpen(false);
1050
- announce("飞书机器人凭据已绑定。");
1051
+ announce(credentialDomain === "lark" ? "Lark 机器人凭据已绑定。" : "飞书机器人凭据已绑定。");
1051
1052
  } catch (error) {
1052
1053
  setCredentialError(presentError(error));
1053
1054
  } finally {
@@ -1055,7 +1056,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1055
1056
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
1056
1057
  setCredentialBusy(false);
1057
1058
  }
1058
- }, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
1059
+ }, [announce, credentialDomain, invoke, loadStatus, mergeSnapshot, workspaceFence]);
1059
1060
 
1060
1061
  const cancelProvisioning = React.useCallback(async () => {
1061
1062
  const activeProvision = model.provisioning;
@@ -1449,16 +1450,30 @@ export function FeishuSettingsTab({ rpcCall }) {
1449
1450
 
1450
1451
  const credentialContent = credentialOpen
1451
1452
  ? h(CredentialBindingPanel, {
1452
- channel: "飞书",
1453
+ key: credentialDomain,
1454
+ channel: credentialDomain === "lark" ? "Lark" : "飞书",
1453
1455
  identityLabel: "App ID",
1454
- identityPlaceholder: "填写飞书开放平台 App ID",
1456
+ identityPlaceholder: credentialDomain === "lark" ? "填写 Lark 开放平台 App ID" : "填写飞书开放平台 App ID",
1455
1457
  secretLabel: "App Secret",
1456
- secretPlaceholder: "填写飞书开放平台 App Secret",
1458
+ secretPlaceholder: credentialDomain === "lark" ? "填写 Lark 开放平台 App Secret" : "填写飞书开放平台 App Secret",
1457
1459
  busy: credentialBusy,
1458
1460
  error: credentialError,
1459
1461
  onSubmit: bindCredentials,
1460
1462
  onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
1461
- })
1463
+ }, h("label", { className: "dim-credentialField" },
1464
+ h("span", null, "应用平台"),
1465
+ h("select", {
1466
+ className: "dim-feishuGroupSelect",
1467
+ "aria-label": "应用平台",
1468
+ value: credentialDomain,
1469
+ disabled: credentialBusy,
1470
+ onChange: (event) => {
1471
+ setCredentialDomain(event.target.value);
1472
+ setCredentialError(null);
1473
+ },
1474
+ },
1475
+ h("option", { value: "feishu" }, "飞书"),
1476
+ h("option", { value: "lark" }, "Lark(国际版)"))))
1462
1477
  : null;
1463
1478
 
1464
1479
  const setCardRef = React.useCallback((botId, node) => {
@@ -47,6 +47,7 @@ export function CredentialBindingPanel({
47
47
  error = null,
48
48
  onSubmit,
49
49
  onCancel,
50
+ children,
50
51
  }) {
51
52
  const [identity, setIdentity] = React.useState('');
52
53
  const [secret, setSecret] = React.useState('');
@@ -66,6 +67,7 @@ export function CredentialBindingPanel({
66
67
  'aria-labelledby': headingId,
67
68
  },
68
69
  h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`),
70
+ children,
69
71
  h('form', {
70
72
  className: `dim-credentialForm${hasIdentity ? '' : ' dim-credentialFormSingle'}`,
71
73
  onSubmit: submit,
@@ -39,6 +39,7 @@ const EN = Object.freeze({
39
39
  'macOS Messages 权限': 'macOS Messages permissions',
40
40
  'IM机器人': 'IM bots',
41
41
  'IM机器人设置': 'IM bot settings',
42
+ 'IM 面板加载失败': 'Could not load the IM panel',
42
43
  '更多机器人设置': 'More bot settings',
43
44
  '机器人设置': 'Bot settings',
44
45
  '机器人设置页签': 'Bot settings tabs',
@@ -698,6 +699,12 @@ const EN = Object.freeze({
698
699
  'Slack 工作区': 'Slack workspace',
699
700
  'Bot Token 与 App Token': 'Bot Token and App Token',
700
701
  '填写 Bot Token': 'Enter Bot Token',
702
+ '应用平台': 'App platform',
703
+ 'Lark(国际版)': 'Lark (international)',
704
+ '手动接入Lark机器人': 'Connect Lark bot manually',
705
+ '填写 Lark 开放平台 App ID': 'Enter the Lark Developer App ID',
706
+ '填写 Lark 开放平台 App Secret': 'Enter the Lark Developer App Secret',
707
+ 'Lark 机器人凭据已绑定。': 'Lark bot credentials connected.',
701
708
  '手动接入飞书机器人': 'Connect Feishu bot manually',
702
709
  '手动接入钉钉机器人': 'Connect DingTalk bot manually',
703
710
  '手动接入企业微信机器人': 'Connect WeCom bot manually',
@@ -74,6 +74,7 @@ import { installImStyles } from './styles.js';
74
74
  import { installSessionChannelLogos } from './session-channel-logos.js';
75
75
  import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
76
76
  import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
77
+ import { IMPanelErrorBoundary } from './panel-error-boundary.js';
77
78
 
78
79
  export const name = 'im-settings';
79
80
  export const inject = ['slots', 'connection', 'locale', 'workspaces'];
@@ -209,10 +210,15 @@ export function IMSettingsTab({
209
210
  deliveryRpcCall,
210
211
  globalSettingsRpcCall,
211
212
  workspaceDirectoryPicker,
213
+ preferredSectionId,
212
214
  browserLocation = globalThis.location,
213
215
  navigateToRecoveryUrl = replacePageLocation,
214
216
  }) {
215
- const [selected, setSelected] = React.useState('weixin');
217
+ const [selected, setSelected] = React.useState(() => (
218
+ preferredSectionId === GLOBAL_SETTINGS_TAB_ID
219
+ || CHANNELS.some((channel) => channel.id === preferredSectionId)
220
+ ? preferredSectionId : 'weixin'
221
+ ));
216
222
  const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
217
223
  const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
218
224
  const [deliverySettings, setDeliverySettings] = React.useState(null);
@@ -471,29 +477,89 @@ export function apply(ctx) {
471
477
  pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'),
472
478
  });
473
479
 
474
- ctx.slots.inject('settings.section', () => ctx.slots.register({
475
- name: 'settings.section',
476
- id: 'xmanrui-dsh-im',
477
- order: 21,
478
- label: () => t('IM机器人'),
479
- locale: IM_LOCALE_NAMESPACE,
480
- inject: () => ({
481
- dingtalkRpcCall,
482
- discordRpcCall,
483
- feishuRpcCall,
484
- qqRpcCall,
485
- slackRpcCall,
486
- telegramRpcCall,
487
- wecomRpcCall,
488
- wecomAppRpcCall,
489
- weixinRpcCall,
490
- whatsappRpcCall,
491
- imessageRpcCall,
492
- officeRpcCall,
493
- updateRpcCall,
494
- deliveryRpcCall,
495
- globalSettingsRpcCall,
496
- workspaceDirectoryPicker,
497
- }),
498
- }, IMSettingsTab));
480
+ const panelDependencies = {
481
+ dingtalkRpcCall,
482
+ discordRpcCall,
483
+ feishuRpcCall,
484
+ qqRpcCall,
485
+ slackRpcCall,
486
+ telegramRpcCall,
487
+ wecomRpcCall,
488
+ wecomAppRpcCall,
489
+ weixinRpcCall,
490
+ whatsappRpcCall,
491
+ imessageRpcCall,
492
+ officeRpcCall,
493
+ updateRpcCall,
494
+ deliveryRpcCall,
495
+ globalSettingsRpcCall,
496
+ workspaceDirectoryPicker,
497
+ };
498
+ const subscribeLocale = (listener) => typeof ctx.on === 'function'
499
+ ? ctx.on('locale/change', listener) : () => {};
500
+ const localeSnapshot = () => ctx.locale.getLocale?.()?.active ?? '';
501
+
502
+ // Stable for this plugin lifetime: creating an element must not create a
503
+ // new component type and reset the reader's selected tab or unsaved input.
504
+ function IMPanel({ preferredSectionId }) {
505
+ React.useSyncExternalStore(subscribeLocale, localeSnapshot, localeSnapshot);
506
+ return h(IMPanelErrorBoundary, null,
507
+ h(IMSettingsTab, { ...panelDependencies, preferredSectionId }));
508
+ }
509
+ const buildPanelElement = (props = {}) => h(IMPanel, {
510
+ preferredSectionId: props.preferredSectionId,
511
+ });
512
+
513
+ ctx.effect(() => {
514
+ let disposed = false;
515
+ let stopSettings = null;
516
+ let registered = false;
517
+ const setSettingsVisible = (visible) => {
518
+ if (disposed) return;
519
+ if (typeof visible !== 'boolean') throw new TypeError('visible must be a boolean');
520
+ if (visible === (stopSettings !== null)) return;
521
+ if (!visible) {
522
+ const stop = stopSettings;
523
+ stopSettings = null;
524
+ stop();
525
+ return;
526
+ }
527
+ // The existing slot controller owns late declarations, withdrawal and
528
+ // re-declaration. Cancelling it also cancels a pending registration.
529
+ stopSettings = ctx.slots.inject('settings.section', () => {
530
+ const unregister = ctx.slots.register({
531
+ name: 'settings.section',
532
+ id: 'xmanrui-dsh-im',
533
+ order: 21,
534
+ label: () => t('IM机器人'),
535
+ locale: IM_LOCALE_NAMESPACE,
536
+ inject: () => panelDependencies,
537
+ }, buildPanelElement);
538
+ registered = true;
539
+ return () => {
540
+ registered = false;
541
+ unregister();
542
+ };
543
+ });
544
+ };
545
+
546
+ // Publish only after the default registration is established, so a
547
+ // consumer hiding it during service discovery cannot be overridden.
548
+ setSettingsVisible(true);
549
+ if (typeof ctx.provide === 'function') {
550
+ ctx.provide('dshImClient', Object.freeze({
551
+ version: 1,
552
+ render: (props) => disposed ? null : buildPanelElement(props),
553
+ setSettingsVisible,
554
+ settingsVisible: () => !disposed && registered,
555
+ }));
556
+ }
557
+ return () => {
558
+ disposed = true;
559
+ const stop = stopSettings;
560
+ stopSettings = null;
561
+ registered = false;
562
+ stop?.();
563
+ };
564
+ }, 'im-settings: client panel service');
499
565
  }
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ import { h } from './i18n.js';
3
+
4
+ /** Keep a broken IM subtree local when a shell renders it outside a slot. */
5
+ export class IMPanelErrorBoundary extends React.Component {
6
+ state = { failed: false };
7
+
8
+ static getDerivedStateFromError() {
9
+ return { failed: true };
10
+ }
11
+
12
+ render() {
13
+ if (!this.state.failed) return this.props.children;
14
+ return h('section', { className: 'dim-page', role: 'alert' },
15
+ h('p', null, 'IM 面板加载失败'),
16
+ h('button', {
17
+ type: 'button',
18
+ onClick: () => this.setState({ failed: false }),
19
+ }, '重试'));
20
+ }
21
+ }
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -58,7 +59,7 @@ export async function createProductionController(ctx, config = {}, internals = {
58
59
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
59
60
  const paths = pluginPaths(config);
60
61
  const configStore = await new ConfigStore(paths.config).load();
61
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
62
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
62
63
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
63
64
  const workspaces = internals.workspaces
64
65
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
105
106
  const harness = new Harness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -26,6 +26,7 @@ import {
26
26
  createWorkspaceAwareController,
27
27
  observeBotWorkspaceRemovals,
28
28
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
29
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
29
30
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
30
31
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
31
32
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
117
118
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
118
119
  const paths = pluginPaths(config);
119
120
  const configStore = await new ConfigStore(paths.config).load();
120
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
121
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
121
122
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
122
123
  const workspaces = internals.workspaces
123
124
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -201,6 +202,7 @@ export async function createProductionController(ctx, config = {}, internals = {
201
202
  const harness = new Harness({
202
203
  ...connection,
203
204
  workspace: defaultWorkspace,
205
+ ungroupedWorkspace,
204
206
  // This plugin is already hosted by a running DSH process. Starting a
205
207
  // second DSH would create a competing server and lifecycle.
206
208
  autostart: false,
@@ -401,9 +401,10 @@ function validPayload(endpoint, payload) {
401
401
  : 'Group message permission update requires a single valid botId.';
402
402
  }
403
403
  if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
404
- return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
404
+ return hasOnlyKeys(payload, new Set(['appId', 'appSecret', 'domain']))
405
405
  && validCredential(payload.appId, 256)
406
406
  && validCredential(payload.appSecret, 1024)
407
+ && (payload.domain === undefined || payload.domain === 'feishu' || payload.domain === 'lark')
407
408
  ? null
408
409
  : 'Credential binding requires App ID and App Secret.';
409
410
  }
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
55
56
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
56
57
  const paths = pluginPaths(config);
57
58
  const configStore = await new ConfigStore(paths.config).load();
58
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
59
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
59
60
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
60
61
  const workspaces = internals.workspaces
61
62
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -96,6 +97,7 @@ export async function createProductionController(ctx, config = {}, internals = {
96
97
  const harness = new Harness({
97
98
  ...connection,
98
99
  workspace: defaultWorkspace,
100
+ ungroupedWorkspace,
99
101
  autostart: false,
100
102
  dshBin: config.dshBin ?? 'dsh',
101
103
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -16,6 +16,7 @@ import {
16
16
  createWorkspaceAwareController,
17
17
  observeBotWorkspaceRemovals,
18
18
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
19
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
19
20
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
20
21
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
21
22
  import {
@@ -65,7 +66,7 @@ export async function createTokenProductionController(ctx, config, internals, de
65
66
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
66
67
  const paths = pluginPaths(config, channel);
67
68
  const configStore = await new ResolvedConfigStore(paths.config).load();
68
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
69
70
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
71
  const workspaces = internals.workspaces
71
72
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createTokenProductionController(ctx, config, internals, de
105
106
  const harness = new ResolvedHarness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -12,6 +12,7 @@ import {
12
12
  createWorkspaceAwareController,
13
13
  observeBotWorkspaceRemovals,
14
14
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
15
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
15
16
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
16
17
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
17
18
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -44,7 +45,7 @@ export async function createProductionController(ctx, config = {}, internals = {
44
45
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
45
46
  const paths = pluginPaths(config, 'slack');
46
47
  const configStore = await new ResolvedConfigStore(paths.config).load();
47
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
48
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
48
49
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
49
50
  const workspaces = internals.workspaces
50
51
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -84,6 +85,7 @@ export async function createProductionController(ctx, config = {}, internals = {
84
85
  const harness = new ResolvedHarness({
85
86
  ...connection,
86
87
  workspace: defaultWorkspace,
88
+ ungroupedWorkspace,
87
89
  autostart: false,
88
90
  dshBin: config.dshBin ?? 'dsh',
89
91
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
55
56
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
56
57
  const paths = pluginPaths(config);
57
58
  const configStore = await new ConfigStore(paths.config).load();
58
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
59
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
59
60
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
60
61
  const workspaces = internals.workspaces
61
62
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -99,6 +100,7 @@ export async function createProductionController(ctx, config = {}, internals = {
99
100
  const harness = new Harness({
100
101
  ...connection,
101
102
  workspace: defaultWorkspace,
103
+ ungroupedWorkspace,
102
104
  autostart: false,
103
105
  dshBin: config.dshBin ?? 'dsh',
104
106
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -69,7 +70,7 @@ export async function createProductionController(ctx, config = {}, internals = {
69
70
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
70
71
  const paths = pluginPaths(config);
71
72
  const configStore = await new ConfigStore(paths.config).load();
72
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
73
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
73
74
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
74
75
  const workspaces = internals.workspaces
75
76
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -133,6 +134,7 @@ export async function createProductionController(ctx, config = {}, internals = {
133
134
  const harness = new Harness({
134
135
  ...connection,
135
136
  workspace: defaultWorkspace,
137
+ ungroupedWorkspace,
136
138
  autostart: false,
137
139
  dshBin: config.dshBin ?? 'dsh',
138
140
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -18,6 +18,7 @@ import {
18
18
  createWorkspaceAwareController,
19
19
  observeBotWorkspaceRemovals,
20
20
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
21
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
21
22
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
22
23
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
23
24
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -90,7 +91,7 @@ export async function createProductionController(ctx, config = {}, internals = {
90
91
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
91
92
  const paths = pluginPaths(config);
92
93
  const configStore = await new ConfigStore(paths.config).load();
93
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
94
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
94
95
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
95
96
  const workspaces = diagnosticWorkspaces(internals.workspaces
96
97
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
@@ -131,6 +132,7 @@ export async function createProductionController(ctx, config = {}, internals = {
131
132
  const harness = new Harness({
132
133
  ...connection,
133
134
  workspace: defaultWorkspace,
135
+ ungroupedWorkspace,
134
136
  autostart: false,
135
137
  dshBin: config.dshBin ?? 'dsh',
136
138
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -17,6 +17,7 @@ import {
17
17
  createWorkspaceAwareController,
18
18
  observeBotWorkspaceRemovals,
19
19
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
20
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
20
21
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
21
22
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
22
23
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -65,7 +66,7 @@ export async function createProductionController(ctx, config = {}, internals = {
65
66
  const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
66
67
  const paths = pluginPaths(config);
67
68
  const configStore = await new ConfigStore(paths.config).load();
68
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
69
70
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
71
  const workspaces = internals.workspaces
71
72
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
105
106
  const harness = new Harness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -5,6 +5,7 @@ import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises
5
5
  import { request as httpRequest } from 'node:http';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, join, resolve } from 'node:path';
8
+ import { setTimeout as delay } from 'node:timers/promises';
8
9
 
9
10
  const harnessRoot = process.argv[2];
10
11
  if (!harnessRoot || process.argv.includes('--help')) {
@@ -18,6 +19,7 @@ await access(join(pluginRoot, 'lib/index.js'));
18
19
  // DSH 0.1.5 CLI intentionally permits only loopback listening. Exercise its
19
20
  // real HTTP carrier with a trusted LAN authority, while keeping TCP local.
20
21
  const lanIp = '192.168.1.100';
22
+ const domain = 'dsh.example.test';
21
23
 
22
24
  const directory = await mkdtemp(join(tmpdir(), 'dsh-im-lan-test-'));
23
25
  const home = join(directory, 'home');
@@ -43,12 +45,12 @@ function request(url, { method = 'GET', headers = {}, body } = {}) {
43
45
  });
44
46
  }
45
47
 
46
- function start() {
48
+ function start(trustedHosts = [lanIp]) {
47
49
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
48
50
  !/^DSH_/i.test(key) && !/(?:KEY|SECRET|TOKEN|PASSWORD|PROXY)/i.test(key)
49
51
  )));
50
52
  child = spawn(process.execPath, [cli, 'web', '--no-open', '--host', '127.0.0.1',
51
- '--port', '0', '--trusted-host', lanIp], {
53
+ '--port', '0', ...trustedHosts.flatMap(host => ['--trusted-host', host])], {
52
54
  cwd: directory,
53
55
  env: { ...env, DSH_HOME: home, DSH_AGENTS_HOME: join(directory, '.agents'),
54
56
  DSH_TELEMETRY_DISABLED: '1', SSH_CONNECTION: '', SSH_TTY: '' },
@@ -107,6 +109,18 @@ function rpc(browser, channel = 'feishu', method = 'connection.status', headers
107
109
  });
108
110
  }
109
111
 
112
+ async function readyStatus(browser, channel = 'feishu') {
113
+ const deadline = Date.now() + 10_000;
114
+ // HTTP readiness can precede the channel controllers finishing startup.
115
+ for (;;) {
116
+ const response = await rpc(browser, channel);
117
+ const result = response.status === 200 ? JSON.parse(response.body).result : null;
118
+ if (result?.ok !== false || result.error?.code !== `${channel}-initializing`
119
+ || Date.now() >= deadline) return response;
120
+ await delay(100);
121
+ }
122
+ }
123
+
110
124
  function expectStatus(name, response, expected, businessOk = false) {
111
125
  assert.equal(response.status, expected, `${name}: ${response.body.slice(0, 300)}`);
112
126
  if (businessOk) {
@@ -146,9 +160,10 @@ try {
146
160
  expectStatus('LAN authenticated web page', await request(new URL('/', lan.origin), {
147
161
  headers: { cookie: lan.cookie },
148
162
  }), 200);
149
- for (const channel of ['feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
150
- 'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office']) {
151
- expectStatus(`LAN default: ${channel}`, await rpc(lan, channel), 200, true);
163
+ const channels = ['feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
164
+ 'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office'];
165
+ for (const channel of channels) {
166
+ expectStatus(`LAN default: ${channel}`, await readyStatus(lan, channel), 200, true);
152
167
  }
153
168
  const delivery = await rpc(lan, 'dsh-im-delivery', 'target.list', {}, { botId: 'bot_missing' });
154
169
  expectStatus('LAN delivery reaches business handler', delivery, 200);
@@ -159,15 +174,45 @@ try {
159
174
  expectStatus('LAN update remains local-only', await rpc(lan, 'dsh-im', 'update.status'), 403);
160
175
  expectStatus('LAN TTL remains local-only', await rpc(lan, 'dsh-im-settings', 'settings.inbound-ttl.get'), 403);
161
176
  const local = await login(launchUrl, '127.0.0.1');
162
- expectStatus('Loopback default: feishu', await rpc(local), 200, true);
177
+ const localhost = await login(launchUrl, 'localhost');
178
+ for (const browser of [local, localhost]) {
179
+ const hostname = new URL(browser.origin).hostname;
180
+ for (const channel of channels) {
181
+ expectStatus(`${hostname} default: ${channel}`, await readyStatus(browser, channel), 200, true);
182
+ }
183
+ // Emulate a browser/proxy dropping the Origin port; this is a header-level
184
+ // reproduction, not evidence that a particular browser emits that header.
185
+ expectStatus(`${hostname} port-less Origin rejected`, await rpc(browser, 'dingtalk',
186
+ 'connection.status', { origin: `http://${hostname}` }), 403);
187
+ expectStatus(`${hostname} cross-site request rejected`, await rpc(browser, 'dingtalk',
188
+ 'connection.status', { 'sec-fetch-site': 'cross-site' }), 403);
189
+ }
190
+ expectStatus('Loopback Host/Origin hostname mismatch rejected', await rpc(local, 'dingtalk',
191
+ 'connection.status', { origin: localhost.origin }), 403);
192
+ expectStatus('Authenticated domain without --trusted-host rejected',
193
+ await rpc(await login(launchUrl, domain), 'dingtalk'), 403);
194
+
195
+ await stop();
196
+ const domainLaunchUrl = await start([lanIp, domain]);
197
+ const trustedDomain = await login(domainLaunchUrl, domain);
198
+ expectStatus('Trusted domain without login rejected',
199
+ await rpc({ origin: trustedDomain.origin }, 'dingtalk'), 401);
200
+ for (const channel of channels) {
201
+ expectStatus(`Domain with --trusted-host: ${channel}`, await readyStatus(trustedDomain, channel), 200, true);
202
+ }
203
+ const otherTrustedOrigin = new URL(trustedDomain.origin);
204
+ otherTrustedOrigin.hostname = lanIp;
205
+ expectStatus('Two trusted hosts still require matching Origin', await rpc(trustedDomain,
206
+ 'dingtalk', 'connection.status', { origin: otherTrustedOrigin.origin }), 403);
163
207
 
164
208
  await stop();
165
209
  await writeFile(join(profile, 'cordis.patch.yml'), '- id: xmanrui-dsh-im\n config:\n rpcAuthority: loopback\n');
166
210
  const restrictedUrl = await start();
167
211
  expectStatus('Explicit loopback rejects LAN', await rpc(await login(restrictedUrl, lanIp)), 403);
168
- expectStatus('Explicit loopback accepts localhost', await rpc(await login(restrictedUrl, '127.0.0.1')), 200, true);
212
+ expectStatus('Explicit loopback accepts 127.0.0.1', await readyStatus(await login(restrictedUrl, '127.0.0.1')), 200, true);
213
+ expectStatus('Explicit loopback accepts localhost', await readyStatus(await login(restrictedUrl, 'localhost')), 200, true);
169
214
  console.table(results);
170
- console.log(`Passed ${results.length} real HTTP checks using LAN Host/Origin ${lanIp}.`);
215
+ console.log(`Passed ${results.length} real HTTP checks using loopback, LAN ${lanIp} and domain ${domain}.`);
171
216
  console.log('TCP connections stayed on loopback. This checks the original CLI, authentication and built plugin, not a second-device browser.');
172
217
  console.log('The temporary profile contains no bot credentials and is removed after the server stops.');
173
218
  } finally {