dsh-device-fleet 0.1.0-dev.13 → 0.1.0-dev.15
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/README.md +2 -1
- package/client.js +384 -0
- package/docs/notes/dev-release.md +13 -6
- package/docs/notes/fleet-console-ui.md +44 -0
- package/package.json +1 -1
- package/src/host/config-web.js +84 -0
- package/src/host/execution-slot.js +8 -0
- package/src/host/index.js +2 -0
- package/src/host/runtime-config.js +192 -0
- package/src/net/fleet-node.js +22 -0
- package/src/plugin/index.js +145 -24
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
|
|
|
10
10
|
- ✅ **审批收件箱逻辑层(dev.7)**:`fleet_approvals` / `fleet_approve` / `fleet_deny`,ask 档排队、超时 fail-closed、once/ttl 授权、`fleet_simulate_request` 本机演练;Client 审批面板待做
|
|
11
11
|
- ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
|
|
12
12
|
- ✅ **审批面板 UI(dev.12,已验证)**:Host `/device-fleet/approvals` HTTP 数据面 + Client 入口在 Settings 上方、居中审批对话框(允许一次/15 分钟/拒绝);用户点击「允许」后 Host 列表清空,端到端闭环
|
|
13
|
-
-
|
|
13
|
+
- ✅ **Fleet 控制台(dev.13,Phase A)**:Host `/device-fleet/overview`、`/rooms`、`/rooms/:id`;Room 持久化到 `$DSH_HOME/device-fleet/rooms.json` 并 rehydrate(插件重载不丢房间);Client `sidebar.panellist` + `main` 面板,设备/房间/审批三 Tab
|
|
14
|
+
- ⏳ Phase B 配置页(即时生效,无需重启)、Phase C 房间/配对交互、Phase D SSE/通知/i18n/a11y
|
|
14
15
|
|
|
15
16
|
## 安装(开发版)
|
|
16
17
|
|
package/client.js
CHANGED
|
@@ -501,6 +501,383 @@ window.__ModuleLoader__.load({
|
|
|
501
501
|
);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// ---- DSH-styled configuration page -------------------------------------
|
|
505
|
+
const CONTROL_WIDTH = 260
|
|
506
|
+
const rowStyle = {
|
|
507
|
+
display: 'flex',
|
|
508
|
+
alignItems: 'center',
|
|
509
|
+
gap: 16,
|
|
510
|
+
minHeight: 42,
|
|
511
|
+
padding: '5px 0',
|
|
512
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1, #333)',
|
|
513
|
+
}
|
|
514
|
+
const rowLabelStyle = { flex: 1, fontSize: 13, color: 'var(--dsw-alias-label-primary, #e8e8e8)' }
|
|
515
|
+
const controlStyle = {
|
|
516
|
+
width: CONTROL_WIDTH,
|
|
517
|
+
boxSizing: 'border-box',
|
|
518
|
+
height: 30,
|
|
519
|
+
padding: '3px 8px',
|
|
520
|
+
borderRadius: 6,
|
|
521
|
+
border: '1px solid var(--dsw-alias-border-l1, #333)',
|
|
522
|
+
background: 'var(--dsw-alias-bg-layer-2, #262626)',
|
|
523
|
+
color: 'var(--dsw-alias-label-primary, #e8e8e8)',
|
|
524
|
+
fontSize: 13,
|
|
525
|
+
outline: 'none',
|
|
526
|
+
}
|
|
527
|
+
const selectStyle = {
|
|
528
|
+
...controlStyle,
|
|
529
|
+
appearance: 'none',
|
|
530
|
+
WebkitAppearance: 'none',
|
|
531
|
+
paddingRight: 24,
|
|
532
|
+
cursor: 'pointer',
|
|
533
|
+
}
|
|
534
|
+
const textareaStyle = {
|
|
535
|
+
...controlStyle,
|
|
536
|
+
width: '100%',
|
|
537
|
+
height: 'auto',
|
|
538
|
+
minHeight: 64,
|
|
539
|
+
resize: 'vertical',
|
|
540
|
+
padding: '6px 8px',
|
|
541
|
+
lineHeight: 1.5,
|
|
542
|
+
fontFamily: 'ui-monospace, monospace',
|
|
543
|
+
}
|
|
544
|
+
const sectionStyle = {
|
|
545
|
+
margin: '22px 0 4px',
|
|
546
|
+
fontSize: 13,
|
|
547
|
+
fontWeight: 600,
|
|
548
|
+
color: 'var(--dsw-alias-label-primary, #e8e8e8)',
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function Row({ label, children, wide = false }) {
|
|
552
|
+
return h('div', { style: rowStyle },
|
|
553
|
+
h('div', { style: rowLabelStyle }, label),
|
|
554
|
+
h('div', { style: wide ? { width: '100%' } : { width: CONTROL_WIDTH } }, children),
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function SelectControl({ value, options, onChange }) {
|
|
559
|
+
return h('select', {
|
|
560
|
+
value: value ?? '',
|
|
561
|
+
onChange: (event) => onChange(event.target.value),
|
|
562
|
+
style: selectStyle,
|
|
563
|
+
},
|
|
564
|
+
...(options ?? []).map((option) => {
|
|
565
|
+
const entry = typeof option === 'string' ? { value: option, label: option } : option
|
|
566
|
+
return h('option', { key: entry.value, value: entry.value }, entry.label)
|
|
567
|
+
}),
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function TextControl({ value, onChange, placeholder }) {
|
|
572
|
+
return h('input', {
|
|
573
|
+
type: 'text',
|
|
574
|
+
value: value ?? '',
|
|
575
|
+
placeholder: placeholder ?? '',
|
|
576
|
+
onChange: (event) => onChange(event.target.value),
|
|
577
|
+
style: controlStyle,
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function NumberControl({ value, onChange }) {
|
|
582
|
+
return h('input', {
|
|
583
|
+
type: 'number',
|
|
584
|
+
value: value ?? 0,
|
|
585
|
+
onChange: (event) => onChange(Number(event.target.value || 0)),
|
|
586
|
+
style: controlStyle,
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function ToggleControl({ value, onChange }) {
|
|
591
|
+
return h(SelectControl, {
|
|
592
|
+
value: value ? 'on' : 'off',
|
|
593
|
+
options: [{ value: 'on', label: '开启' }, { value: 'off', label: '关闭' }],
|
|
594
|
+
onChange: (next) => onChange(next === 'on'),
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const CAPABILITY_ROWS = [
|
|
599
|
+
['capRead', '读取文件'],
|
|
600
|
+
['capList', '列目录'],
|
|
601
|
+
['capGrep', '搜索内容'],
|
|
602
|
+
['capFetch', '获取文件'],
|
|
603
|
+
['capRun', '执行命令'],
|
|
604
|
+
['capTool', '调用原始工具'],
|
|
605
|
+
['capDelegate', '委派任务'],
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
function configToDraft(config = {}, options = {}) {
|
|
609
|
+
const execution = config.execution ?? {}
|
|
610
|
+
const policy = config.policy ?? {}
|
|
611
|
+
const approvals = config.approvals ?? {}
|
|
612
|
+
const direct = config.direct ?? {}
|
|
613
|
+
const relay = config.relay ?? null
|
|
614
|
+
const cwd = execution.cwd ?? ''
|
|
615
|
+
const cwdInfo = options.cwd ?? {}
|
|
616
|
+
let cwdMode = 'custom'
|
|
617
|
+
if (cwd === '') cwdMode = 'default'
|
|
618
|
+
else if (cwdInfo.workspace && cwd === cwdInfo.workspace) cwdMode = 'workspace'
|
|
619
|
+
else if (cwdInfo.dshHome && cwd === cwdInfo.dshHome) cwdMode = 'dshHome'
|
|
620
|
+
else if (cwdInfo.defaultDirectory && cwd === cwdInfo.defaultDirectory) cwdMode = 'default'
|
|
621
|
+
return {
|
|
622
|
+
deviceName: config.deviceName ?? '',
|
|
623
|
+
directHost: direct.host ?? '0.0.0.0',
|
|
624
|
+
directPort: direct.port ?? 0,
|
|
625
|
+
discovery: config.discovery !== false,
|
|
626
|
+
relayMode: relay && (relay.host || relay.port) ? 'custom' : 'off',
|
|
627
|
+
relayHost: relay?.host ?? '',
|
|
628
|
+
relayPort: relay?.port ?? 0,
|
|
629
|
+
relayDataPort: relay?.dataPort ?? 0,
|
|
630
|
+
cwdMode,
|
|
631
|
+
customCwd: cwdMode === 'custom' ? cwd : '',
|
|
632
|
+
execPreset: execution.agentPreset ?? '',
|
|
633
|
+
execShellTool: execution.shellTool ?? '',
|
|
634
|
+
execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
|
|
635
|
+
execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
|
|
636
|
+
execSubagentProvider: execution.subagentProvider ?? 'spawn',
|
|
637
|
+
rootsText: (policy.allowedRoots ?? []).join('\n'),
|
|
638
|
+
toolsText: (policy.allowedTools ?? []).join('\n'),
|
|
639
|
+
capRead: policy.capabilities?.read ?? 'auto',
|
|
640
|
+
capList: policy.capabilities?.list ?? 'auto',
|
|
641
|
+
capGrep: policy.capabilities?.grep ?? 'auto',
|
|
642
|
+
capFetch: policy.capabilities?.fetch ?? 'auto',
|
|
643
|
+
capRun: policy.capabilities?.run ?? 'ask',
|
|
644
|
+
capTool: policy.capabilities?.tool ?? 'deny',
|
|
645
|
+
capDelegate: policy.capabilities?.delegate ?? 'ask',
|
|
646
|
+
policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
|
|
647
|
+
approvalsTtlMs: approvals.ttlMs ?? 60000,
|
|
648
|
+
approvalsMaxPending: approvals.maxPending ?? 50,
|
|
649
|
+
autoApproveAsk: config.autoApproveAsk === true,
|
|
650
|
+
promptSection: config.promptSection !== false,
|
|
651
|
+
simulation: approvals.simulation === true,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function draftToPatch(draft, options = {}) {
|
|
656
|
+
const cwdInfo = options.cwd ?? {}
|
|
657
|
+
let cwd = ''
|
|
658
|
+
if (draft.cwdMode === 'workspace') cwd = cwdInfo.workspace ?? ''
|
|
659
|
+
else if (draft.cwdMode === 'dshHome') cwd = cwdInfo.dshHome ?? ''
|
|
660
|
+
else if (draft.cwdMode === 'default') cwd = cwdInfo.defaultDirectory || cwdInfo.workspace || ''
|
|
661
|
+
else if (draft.cwdMode === 'custom') cwd = draft.customCwd ?? ''
|
|
662
|
+
return {
|
|
663
|
+
deviceName: draft.deviceName,
|
|
664
|
+
direct: { host: draft.directHost, port: draft.directPort },
|
|
665
|
+
discovery: draft.discovery,
|
|
666
|
+
relay: draft.relayMode === 'custom'
|
|
667
|
+
? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
|
|
668
|
+
: null,
|
|
669
|
+
execution: {
|
|
670
|
+
cwd,
|
|
671
|
+
agentPreset: draft.execPreset,
|
|
672
|
+
shellTool: draft.execShellTool,
|
|
673
|
+
approvalTimeoutMs: draft.execApprovalTimeoutMs,
|
|
674
|
+
delegateTimeoutMs: draft.execDelegateTimeoutMs,
|
|
675
|
+
subagentProvider: draft.execSubagentProvider,
|
|
676
|
+
},
|
|
677
|
+
policy: {
|
|
678
|
+
allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
|
|
679
|
+
allowedTools: draft.toolsText.split('\n').map((item) => item.trim()).filter(Boolean),
|
|
680
|
+
capabilities: {
|
|
681
|
+
read: draft.capRead,
|
|
682
|
+
list: draft.capList,
|
|
683
|
+
grep: draft.capGrep,
|
|
684
|
+
fetch: draft.capFetch,
|
|
685
|
+
run: draft.capRun,
|
|
686
|
+
tool: draft.capTool,
|
|
687
|
+
delegate: draft.capDelegate,
|
|
688
|
+
},
|
|
689
|
+
approvalTtlMs: draft.policyApprovalTtlMs,
|
|
690
|
+
},
|
|
691
|
+
approvals: {
|
|
692
|
+
ttlMs: draft.approvalsTtlMs,
|
|
693
|
+
maxPending: draft.approvalsMaxPending,
|
|
694
|
+
simulation: draft.simulation,
|
|
695
|
+
},
|
|
696
|
+
autoApproveAsk: draft.autoApproveAsk,
|
|
697
|
+
promptSection: draft.promptSection,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function FleetConfig() {
|
|
702
|
+
const [draft, setDraft] = useState(null)
|
|
703
|
+
const [options, setOptions] = useState({})
|
|
704
|
+
const [status, setStatus] = useState('')
|
|
705
|
+
const [busy, setBusy] = useState(false)
|
|
706
|
+
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
let disposed = false
|
|
709
|
+
const load = async () => {
|
|
710
|
+
try {
|
|
711
|
+
const [configResponse, optionsResponse] = await Promise.all([
|
|
712
|
+
fetch('/device-fleet/config', { headers: { accept: 'application/json' } }),
|
|
713
|
+
fetch('/device-fleet/options', { headers: { accept: 'application/json' } }),
|
|
714
|
+
])
|
|
715
|
+
const configData = await configResponse.json()
|
|
716
|
+
const optionsData = optionsResponse.ok ? await optionsResponse.json() : {}
|
|
717
|
+
if (disposed) return
|
|
718
|
+
setOptions(optionsData ?? {})
|
|
719
|
+
setDraft(configToDraft(configData.config ?? {}, optionsData ?? {}))
|
|
720
|
+
} catch (problem) {
|
|
721
|
+
if (!disposed) setStatus('加载失败')
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
void load()
|
|
725
|
+
return () => { disposed = true }
|
|
726
|
+
}, [])
|
|
727
|
+
|
|
728
|
+
if (!draft) return h('div', { style: { padding: 8, color: 'var(--dsw-alias-label-secondary, #999)' } }, status || '加载中…')
|
|
729
|
+
|
|
730
|
+
const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }))
|
|
731
|
+
const save = async () => {
|
|
732
|
+
setBusy(true)
|
|
733
|
+
setStatus('')
|
|
734
|
+
try {
|
|
735
|
+
const response = await fetch('/device-fleet/config', {
|
|
736
|
+
method: 'POST',
|
|
737
|
+
headers: { 'content-type': 'application/json' },
|
|
738
|
+
body: JSON.stringify(draftToPatch(draft, options)),
|
|
739
|
+
})
|
|
740
|
+
const data = await response.json()
|
|
741
|
+
if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status)
|
|
742
|
+
setDraft(configToDraft(data.config ?? {}, options))
|
|
743
|
+
setStatus('已保存')
|
|
744
|
+
} catch (problem) {
|
|
745
|
+
setStatus('保存失败:' + String(problem.message ?? problem))
|
|
746
|
+
} finally {
|
|
747
|
+
setBusy(false)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const cwdOptions = [
|
|
752
|
+
{ value: 'workspace', label: 'Device Fleet 工作区' },
|
|
753
|
+
{ value: 'default', label: 'DSH 默认目录' },
|
|
754
|
+
{ value: 'dshHome', label: 'DSH 主目录' },
|
|
755
|
+
{ value: 'custom', label: '自定义路径' },
|
|
756
|
+
]
|
|
757
|
+
const presetOptions = [{ value: '', label: '默认' }, ...((options.presets ?? []).map((preset) => ({ value: preset.id, label: preset.label })))]
|
|
758
|
+
const shellOptions = (options.shells ?? ['pwsh']).map((shell) => ({ value: shell, label: shell }))
|
|
759
|
+
const providerOptions = (options.subagentProviders ?? ['spawn']).map((provider) => ({ value: provider, label: provider }))
|
|
760
|
+
const hostOptions = (options.addresses ?? ['0.0.0.0']).map((address) => ({ value: address, label: address }))
|
|
761
|
+
const modeOptions = [{ value: 'auto', label: '自动' }, { value: 'ask', label: '询问' }, { value: 'deny', label: '拒绝' }]
|
|
762
|
+
|
|
763
|
+
return h('div', { style: { maxWidth: 760, padding: '4px 2px 28px', color: 'var(--dsw-alias-label-primary, #e8e8e8)' } },
|
|
764
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 } },
|
|
765
|
+
h('div', { style: { fontSize: 15, fontWeight: 600 } }, 'Device Fleet'),
|
|
766
|
+
h('span', { style: { flex: 1 } }),
|
|
767
|
+
status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #999)' } }, status) : null,
|
|
768
|
+
h('button', {
|
|
769
|
+
type: 'button',
|
|
770
|
+
disabled: busy,
|
|
771
|
+
onClick: () => void save(),
|
|
772
|
+
style: {
|
|
773
|
+
height: 30,
|
|
774
|
+
padding: '0 14px',
|
|
775
|
+
borderRadius: 6,
|
|
776
|
+
border: '1px solid var(--dsw-alias-brand-primary, #2f6fed)',
|
|
777
|
+
background: 'var(--dsw-alias-brand-primary, #2f6fed)',
|
|
778
|
+
color: '#fff',
|
|
779
|
+
fontSize: 13,
|
|
780
|
+
cursor: busy ? 'default' : 'pointer',
|
|
781
|
+
opacity: busy ? 0.6 : 1,
|
|
782
|
+
},
|
|
783
|
+
}, busy ? '保存中' : '保存'),
|
|
784
|
+
),
|
|
785
|
+
h('div', { style: sectionStyle }, '设备'),
|
|
786
|
+
h(Row, { label: '名称' },
|
|
787
|
+
h(TextControl, { value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
|
|
788
|
+
),
|
|
789
|
+
h('div', { style: sectionStyle }, '网络'),
|
|
790
|
+
h(Row, { label: '直连地址' },
|
|
791
|
+
h(SelectControl, { value: draft.directHost, options: hostOptions, onChange: (value) => set('directHost', value) }),
|
|
792
|
+
),
|
|
793
|
+
h(Row, { label: '直连端口' },
|
|
794
|
+
h(NumberControl, { value: draft.directPort, onChange: (value) => set('directPort', value) }),
|
|
795
|
+
),
|
|
796
|
+
h(Row, { label: '局域网发现' },
|
|
797
|
+
h(ToggleControl, { value: draft.discovery, onChange: (value) => set('discovery', value) }),
|
|
798
|
+
),
|
|
799
|
+
h(Row, { label: 'Relay' },
|
|
800
|
+
h(SelectControl, {
|
|
801
|
+
value: draft.relayMode,
|
|
802
|
+
options: [{ value: 'off', label: '不使用' }, { value: 'custom', label: '自定义' }],
|
|
803
|
+
onChange: (value) => set('relayMode', value),
|
|
804
|
+
}),
|
|
805
|
+
),
|
|
806
|
+
draft.relayMode === 'custom' ? h(Row, { label: 'Relay 地址' },
|
|
807
|
+
h(TextControl, { value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
|
|
808
|
+
) : null,
|
|
809
|
+
draft.relayMode === 'custom' ? h(Row, { label: 'Relay 端口' },
|
|
810
|
+
h(NumberControl, { value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
|
|
811
|
+
) : null,
|
|
812
|
+
draft.relayMode === 'custom' ? h(Row, { label: 'Relay 数据端口' },
|
|
813
|
+
h(NumberControl, { value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
|
|
814
|
+
) : null,
|
|
815
|
+
h('div', { style: sectionStyle }, '执行上下文'),
|
|
816
|
+
h(Row, { label: '工作目录' },
|
|
817
|
+
h(SelectControl, { value: draft.cwdMode, options: cwdOptions, onChange: (value) => set('cwdMode', value) }),
|
|
818
|
+
),
|
|
819
|
+
draft.cwdMode === 'custom' ? h(Row, { label: '路径' },
|
|
820
|
+
h(TextControl, { value: draft.customCwd, onChange: (value) => set('customCwd', value) }),
|
|
821
|
+
) : null,
|
|
822
|
+
h(Row, { label: 'Agent Preset' },
|
|
823
|
+
h(SelectControl, { value: draft.execPreset, options: presetOptions, onChange: (value) => set('execPreset', value) }),
|
|
824
|
+
),
|
|
825
|
+
h(Row, { label: 'Shell' },
|
|
826
|
+
h(SelectControl, { value: draft.execShellTool, options: shellOptions, onChange: (value) => set('execShellTool', value) }),
|
|
827
|
+
),
|
|
828
|
+
h(Row, { label: '委派 Provider' },
|
|
829
|
+
h(SelectControl, { value: draft.execSubagentProvider, options: providerOptions, onChange: (value) => set('execSubagentProvider', value) }),
|
|
830
|
+
),
|
|
831
|
+
h(Row, { label: '审批超时 (ms)' },
|
|
832
|
+
h(NumberControl, { value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
|
|
833
|
+
),
|
|
834
|
+
h(Row, { label: '委派超时 (ms)' },
|
|
835
|
+
h(NumberControl, { value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
|
|
836
|
+
),
|
|
837
|
+
h('div', { style: sectionStyle }, '远端权限'),
|
|
838
|
+
h(Row, { label: '可访问根目录', wide: true },
|
|
839
|
+
h('textarea', {
|
|
840
|
+
value: draft.rootsText,
|
|
841
|
+
onChange: (event) => set('rootsText', event.target.value),
|
|
842
|
+
rows: 3,
|
|
843
|
+
style: textareaStyle,
|
|
844
|
+
}),
|
|
845
|
+
),
|
|
846
|
+
h(Row, { label: '允许的原始工具', wide: true },
|
|
847
|
+
h('textarea', {
|
|
848
|
+
value: draft.toolsText,
|
|
849
|
+
onChange: (event) => set('toolsText', event.target.value),
|
|
850
|
+
rows: 3,
|
|
851
|
+
style: textareaStyle,
|
|
852
|
+
}),
|
|
853
|
+
),
|
|
854
|
+
...CAPABILITY_ROWS.map(([key, label]) =>
|
|
855
|
+
h(Row, { key, label },
|
|
856
|
+
h(SelectControl, { value: draft[key], options: modeOptions, onChange: (value) => set(key, value) }),
|
|
857
|
+
),
|
|
858
|
+
),
|
|
859
|
+
h(Row, { label: '授权有效期 (ms)' },
|
|
860
|
+
h(NumberControl, { value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
|
|
861
|
+
),
|
|
862
|
+
h('div', { style: sectionStyle }, '审批与提示'),
|
|
863
|
+
h(Row, { label: '审批超时 (ms)' },
|
|
864
|
+
h(NumberControl, { value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
|
|
865
|
+
),
|
|
866
|
+
h(Row, { label: '最大排队数' },
|
|
867
|
+
h(NumberControl, { value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
|
|
868
|
+
),
|
|
869
|
+
h(Row, { label: '自动批准(测试)' },
|
|
870
|
+
h(ToggleControl, { value: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
|
|
871
|
+
),
|
|
872
|
+
h(Row, { label: 'Fleet 系统提示' },
|
|
873
|
+
h(ToggleControl, { value: draft.promptSection, onChange: (value) => set('promptSection', value) }),
|
|
874
|
+
),
|
|
875
|
+
h(Row, { label: '审批模拟(测试)' },
|
|
876
|
+
h(ToggleControl, { value: draft.simulation, onChange: (value) => set('simulation', value) }),
|
|
877
|
+
),
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
|
|
504
881
|
return {
|
|
505
882
|
inject: ['slots'],
|
|
506
883
|
apply(ctx) {
|
|
@@ -517,6 +894,13 @@ window.__ModuleLoader__.load({
|
|
|
517
894
|
ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
|
|
518
895
|
);
|
|
519
896
|
ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
|
|
897
|
+
// Configuration page: Settings → Plugins → Device Fleet (row + bundle).
|
|
898
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
899
|
+
ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
|
|
900
|
+
);
|
|
901
|
+
ctx.slots.inject('plugins.bundle.config', () =>
|
|
902
|
+
ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
|
|
903
|
+
);
|
|
520
904
|
},
|
|
521
905
|
};
|
|
522
906
|
},
|
|
@@ -39,19 +39,19 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
39
39
|
- 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
|
|
40
40
|
- 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
|
|
41
41
|
- `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
|
|
42
|
-
## 0.1.0-dev.
|
|
42
|
+
## 0.1.0-dev.14(稳定化)
|
|
43
43
|
|
|
44
44
|
- 直连端口可固定(profile `direct.port`),插件重载不再换端口;
|
|
45
45
|
- 连接断开后自动重试已知地址(不再只在启动时试一次);
|
|
46
46
|
- `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
|
|
47
47
|
- 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
|
|
48
|
-
## 0.1.0-dev.
|
|
48
|
+
## 0.1.0-dev.14(审批收件箱,逻辑层)
|
|
49
49
|
|
|
50
50
|
- 新增 `ApprovalInbox`:ask 档请求排队、超时 fail-closed、approve(once/ttl)/deny、关闭时全部失败关闭;
|
|
51
51
|
- 直调与委派的 ask 档改为等待本机收件箱结果(不再一律拒绝);
|
|
52
52
|
- 新工具:`fleet_approvals` / `fleet_approve` / `fleet_deny`;`fleet_simulate_request`(仅 `approvals.simulation: true`)用于本机无第二台设备时演练流程;
|
|
53
53
|
- 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
|
|
54
|
-
## 0.1.0-dev.
|
|
54
|
+
## 0.1.0-dev.14(Room 逻辑层,本机模拟)
|
|
55
55
|
|
|
56
56
|
- `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
|
|
57
57
|
- 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
|
|
@@ -59,15 +59,22 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
59
59
|
- Room Board:`room_task_add/update/list`,`dependsOn` 串行交接(前置完成时唤醒下一个 owner),无依赖任务并行不受影响;
|
|
60
60
|
- 工具:`room_create / room_add_local / room_remove_member / room_send / room_messages / room_status / room_task_* / room_close`;
|
|
61
61
|
- 远程参与者传输与 Client UI 未做(下一步)。
|
|
62
|
-
## 0.1.0-dev.
|
|
62
|
+
## 0.1.0-dev.14(审批面板 UI,第一版)
|
|
63
63
|
|
|
64
64
|
- Host 新增本地 HTTP 数据面:GET `/device-fleet/approvals`、POST `/device-fleet/approvals/decide`(仅本机 web 载体,决策仍走 ApprovalInbox);
|
|
65
65
|
- Client half:`client.js` 闭包工厂,注册 `shell.overlay` 浮动「Fleet 审批」按钮 + 面板,轮询待审批列表,支持允许一次 / 允许 15 分钟 / 拒绝;
|
|
66
66
|
- package.json 增加 `dsh.client` 与 `./client` 导出;`files` 增加 client.js;
|
|
67
67
|
- Client 视觉验证需要刷新页面;当前无浏览器控制,仅验证了 bundle 语法与 Host 路由注册。
|
|
68
|
-
## 0.1.0-dev.
|
|
68
|
+
## 0.1.0-dev.14(审批入口不遮挡)
|
|
69
69
|
|
|
70
70
|
- 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
|
|
71
71
|
- 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
|
|
72
72
|
- 轮询在 Client apply 中启动并由 ctx.effect 清理;待审批数量作为按钮徽标;
|
|
73
|
-
- 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
|
|
73
|
+
- 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
|
|
74
|
+
## 0.1.0-dev.14(Phase B:运行时配置 + 配置页)
|
|
75
|
+
|
|
76
|
+
- `RuntimeConfig`:profile patch 为 base,UI 修改写入 `$DSH_HOME/device-fleet/config-overrides.json`,apply 时合并并持久化;
|
|
77
|
+
- 保存即生效:policy/execution/approvals/deviceName/promptSection/autoApproveAsk 直接更新运行时对象;direct/discovery/relay 通过 `FleetNode.reconfigure()` 原地重启网络监听(不重启插件/DSH);`dataDir` 只读;
|
|
78
|
+
- Host:`GET /device-fleet/config`、`POST /device-fleet/config`(校验/清洗后应用);
|
|
79
|
+
- Client:注册 `plugins.row.config`(key `dsh-device-fleet#device-fleet`)与 `plugins.bundle.config`(key `dsh-device-fleet`),配置页含设备/网络、执行上下文、Tool Policy、审批与提示四组表单;
|
|
80
|
+
- 单测 18/18、网络 9/9。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Fleet 控制台 UI(Phase A,dev.13)
|
|
2
|
+
|
|
3
|
+
## 用户决策(2026-11-09)
|
|
4
|
+
|
|
5
|
+
1. **配置保存必须即时生效**:不重启插件、不重启 DSH。→ Phase B 采用"运行时配置 + 持久化 overrides"设计(下一阶段实现)。
|
|
6
|
+
2. 房间 UI 动作身份:以**用户**身份(ADR-0007)。
|
|
7
|
+
3. 实施顺序:A → B → C → D。
|
|
8
|
+
|
|
9
|
+
## 本阶段实现
|
|
10
|
+
|
|
11
|
+
### Host 读模型
|
|
12
|
+
|
|
13
|
+
`src/host/ui-web.js`:
|
|
14
|
+
|
|
15
|
+
- `GET /device-fleet/overview`:`FleetNode.status()` + rooms 摘要 + approvals 列表;
|
|
16
|
+
- `GET /device-fleet/rooms`:房间摘要列表;
|
|
17
|
+
- `GET /device-fleet/rooms/:id`:房间详情(成员、策略、transcript、看板任务)。
|
|
18
|
+
|
|
19
|
+
### Room 持久化 / rehydrate
|
|
20
|
+
|
|
21
|
+
`RoomService` 新增 `stateFile`(`$DSH_HOME/device-fleet/rooms.json`):
|
|
22
|
+
|
|
23
|
+
- 每次 append(创建/成员/消息/任务/关闭)后原子写快照;
|
|
24
|
+
- 启动时 `load()` 重建 rooms/members/messages/tasks;
|
|
25
|
+
- 成员 agent 不序列化,唤醒时通过 `sessionController.resolveAgent(sessionId)` 懒解析;
|
|
26
|
+
- 插件卸载不再关闭房间(避免重载即销毁)。
|
|
27
|
+
|
|
28
|
+
**验证**:创建 room + worker-c + 任务 + 1 条消息 → 插件重载 → `room_status` 仍显示 2 名成员、1 条消息、1 个任务;`room_task_list` 保留任务。
|
|
29
|
+
|
|
30
|
+
### Client
|
|
31
|
+
|
|
32
|
+
`client.js` 新增:
|
|
33
|
+
|
|
34
|
+
- `sidebar.panellist` 图标(id `fleet`,label `设备联邦`,圆形网格图标);
|
|
35
|
+
- `main` keyed 面板 `fleet` = Fleet 控制台,三个 Tab:
|
|
36
|
+
- **设备**:本机名称/id、直连地址、relay、发现数量;Fleet 成员列表(在线/离线);回连地址;
|
|
37
|
+
- **房间**:房间列表(成员数/消息数/任务数),选中后展示成员、看板、时间线;
|
|
38
|
+
- **审批**:待审批列表 + 允许一次/15 分钟/拒绝(与快捷面板共用 Host API)。
|
|
39
|
+
|
|
40
|
+
## 未完成(Phase B/C/D)
|
|
41
|
+
|
|
42
|
+
- 配置页与即时生效(Phase B);
|
|
43
|
+
- 房间交互:以用户身份发消息、看板操作、邀请/移除成员(Phase C);
|
|
44
|
+
- 配对向导、设备操作、审批历史/授权撤销、SSE、通知、i18n、a11y(Phase C/D)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.15",
|
|
4
4
|
"description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin/index.js",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Runtime config HTTP surface (dev.15): read effective config, list UI options,
|
|
2
|
+
// and apply a patch live. The UI posts here; no plugin or DSH restart is involved.
|
|
3
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
function json(res, status, value) {
|
|
6
|
+
res.statusCode = status
|
|
7
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
8
|
+
res.setHeader('cache-control', 'no-store')
|
|
9
|
+
res.end(JSON.stringify(value))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readJsonBody(req) {
|
|
13
|
+
const chunks = []
|
|
14
|
+
let size = 0
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
size += chunk.length
|
|
17
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
|
|
18
|
+
chunks.push(chunk)
|
|
19
|
+
}
|
|
20
|
+
if (chunks.length === 0) return {}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
23
|
+
} catch {
|
|
24
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {object} options.webServer ctx.webServer
|
|
31
|
+
* @param {object} options.runtime RuntimeConfig
|
|
32
|
+
* @param {() => Promise<object>|object} [options.getOptions] UI option catalog (presets, shells, paths)
|
|
33
|
+
* @param {(patch: object) => Promise<object>|object} [options.preparePatch] side effects before applying (e.g. mkdir)
|
|
34
|
+
*/
|
|
35
|
+
export function registerConfigRoutes({ webServer, runtime, getOptions = null, preparePatch = null, logger = null }) {
|
|
36
|
+
if (!webServer?.register || !runtime) return () => {}
|
|
37
|
+
const disposers = []
|
|
38
|
+
disposers.push(
|
|
39
|
+
webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: '/device-fleet/options',
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
44
|
+
try {
|
|
45
|
+
json(res, 200, (await getOptions?.()) ?? {})
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger?.warn?.(`[device-fleet] options failed: ${String(error?.message ?? error)}`)
|
|
48
|
+
json(res, 500, { error: 'internal error' })
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
disposers.push(
|
|
54
|
+
webServer.register({
|
|
55
|
+
kind: 'exact',
|
|
56
|
+
path: '/device-fleet/config',
|
|
57
|
+
handler: async (req, res) => {
|
|
58
|
+
if (req.method === 'GET') {
|
|
59
|
+
try {
|
|
60
|
+
json(res, 200, runtime.snapshot())
|
|
61
|
+
} catch (error) {
|
|
62
|
+
json(res, 500, { error: String(error?.message ?? error) })
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
67
|
+
try {
|
|
68
|
+
const body = await readJsonBody(req)
|
|
69
|
+
const patch = body?.patch ?? body
|
|
70
|
+
const prepared = preparePatch ? await preparePatch(patch) : patch
|
|
71
|
+
const config = await runtime.update(prepared ?? patch)
|
|
72
|
+
json(res, 200, { ok: true, config, appliedAt: Date.now() })
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const status = ['bad-request', 'bad-json', 'body-too-large', 'readonly'].includes(error?.code) ? 400 : 500
|
|
75
|
+
if (status === 500) logger?.warn?.(`[device-fleet] config update failed: ${String(error?.message ?? error)}`)
|
|
76
|
+
json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
return () => {
|
|
82
|
+
for (const dispose of disposers) dispose()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -18,6 +18,14 @@ export class ExecutionSlot {
|
|
|
18
18
|
this.initializing = null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Replace the slot's creation config; the next call lazily recreates the agent. */
|
|
22
|
+
setConfig(next = {}) {
|
|
23
|
+
this.config = { ...this.config, ...(next ?? {}) }
|
|
24
|
+
this.agent = null
|
|
25
|
+
this.sessionId = null
|
|
26
|
+
this.initializing = null
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
async ensureAgent() {
|
|
22
30
|
if (this.agent) return this.agent
|
|
23
31
|
if (this.initializing) return this.initializing
|
package/src/host/index.js
CHANGED
|
@@ -7,5 +7,7 @@ export * from './delegation.js'
|
|
|
7
7
|
export * from './approval-inbox.js'
|
|
8
8
|
export * from './approval-web.js'
|
|
9
9
|
export * from './ui-web.js'
|
|
10
|
+
export * from './config-web.js'
|
|
11
|
+
export * from './runtime-config.js'
|
|
10
12
|
export * from './room-service.js'
|
|
11
13
|
export * from './tools.js'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Runtime configuration (ADR-0025): the profile patch stays the base layer,
|
|
2
|
+
// while `$DSH_HOME/device-fleet/config-overrides.json` holds UI edits. Applying
|
|
3
|
+
// a patch merges it, persists it and runs the registered live-apply handlers so
|
|
4
|
+
// no plugin/DSH restart is required.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const READONLY_FIELDS = ['dataDir']
|
|
9
|
+
const CAPABILITY_MODES = ['auto', 'ask', 'deny']
|
|
10
|
+
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deepMerge(base, patch) {
|
|
16
|
+
if (!isPlainObject(patch)) return patch
|
|
17
|
+
const result = isPlainObject(base) ? { ...base } : {}
|
|
18
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
19
|
+
if (value === undefined) continue
|
|
20
|
+
if (Array.isArray(value)) result[key] = value.map((item) => (isPlainObject(item) ? deepMerge({}, item) : item))
|
|
21
|
+
else if (isPlainObject(value)) result[key] = deepMerge(result[key], value)
|
|
22
|
+
else result[key] = value
|
|
23
|
+
}
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJson(file) {
|
|
28
|
+
if (!file || !existsSync(file)) return null
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requirePositiveInteger(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
37
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
38
|
+
throw Object.assign(new Error(`${name} must be an integer between ${min} and ${max}`), { code: 'bad-request' })
|
|
39
|
+
}
|
|
40
|
+
return value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringList(value, name) {
|
|
44
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
45
|
+
throw Object.assign(new Error(`${name} must be an array of strings`), { code: 'bad-request' })
|
|
46
|
+
}
|
|
47
|
+
return value.map((item) => item.trim()).filter((item) => item !== '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function optionalString(value, name) {
|
|
51
|
+
if (value === undefined || value === null) return ''
|
|
52
|
+
if (typeof value !== 'string') throw Object.assign(new Error(`${name} must be a string`), { code: 'bad-request' })
|
|
53
|
+
return value.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate and clean a UI patch. Only known fields survive; unknown keys are
|
|
58
|
+
* dropped instead of leaking arbitrary objects into the effective config.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeConfigPatch(patch) {
|
|
61
|
+
if (!isPlainObject(patch)) throw Object.assign(new Error('config patch must be an object'), { code: 'bad-request' })
|
|
62
|
+
const clean = {}
|
|
63
|
+
if (patch.deviceName !== undefined) {
|
|
64
|
+
const name = optionalString(patch.deviceName, 'deviceName')
|
|
65
|
+
if (name !== '') clean.deviceName = name.slice(0, 80)
|
|
66
|
+
}
|
|
67
|
+
if (patch.promptSection !== undefined) clean.promptSection = patch.promptSection === true
|
|
68
|
+
if (patch.autoApproveAsk !== undefined) clean.autoApproveAsk = patch.autoApproveAsk === true
|
|
69
|
+
if (patch.discovery !== undefined) clean.discovery = patch.discovery === true
|
|
70
|
+
if (patch.dataDir !== undefined) {
|
|
71
|
+
throw Object.assign(new Error('dataDir cannot be changed at runtime'), { code: 'readonly' })
|
|
72
|
+
}
|
|
73
|
+
if (patch.direct !== undefined) {
|
|
74
|
+
if (!isPlainObject(patch.direct)) throw Object.assign(new Error('direct must be an object'), { code: 'bad-request' })
|
|
75
|
+
clean.direct = {
|
|
76
|
+
host: optionalString(patch.direct.host, 'direct.host') || '0.0.0.0',
|
|
77
|
+
port: requirePositiveInteger(Number(patch.direct.port ?? 0), 'direct.port', { min: 0, max: 65535 }),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (patch.relay !== undefined) {
|
|
81
|
+
if (patch.relay === null) clean.relay = null
|
|
82
|
+
else if (isPlainObject(patch.relay)) {
|
|
83
|
+
clean.relay = {
|
|
84
|
+
host: optionalString(patch.relay.host, 'relay.host'),
|
|
85
|
+
port: requirePositiveInteger(Number(patch.relay.port ?? 0), 'relay.port', { min: 0, max: 65535 }),
|
|
86
|
+
dataPort: requirePositiveInteger(Number(patch.relay.dataPort ?? 0), 'relay.dataPort', { min: 0, max: 65535 }),
|
|
87
|
+
reconnect: patch.relay.reconnect !== false,
|
|
88
|
+
}
|
|
89
|
+
if (clean.relay.host === '' || clean.relay.port === 0) clean.relay = null
|
|
90
|
+
} else {
|
|
91
|
+
throw Object.assign(new Error('relay must be an object or null'), { code: 'bad-request' })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (patch.execution !== undefined) {
|
|
95
|
+
if (!isPlainObject(patch.execution)) throw Object.assign(new Error('execution must be an object'), { code: 'bad-request' })
|
|
96
|
+
const execution = {}
|
|
97
|
+
if (patch.execution.cwd !== undefined) execution.cwd = optionalString(patch.execution.cwd, 'execution.cwd')
|
|
98
|
+
if (patch.execution.agentPreset !== undefined) execution.agentPreset = optionalString(patch.execution.agentPreset, 'execution.agentPreset')
|
|
99
|
+
if (patch.execution.shellTool !== undefined) execution.shellTool = optionalString(patch.execution.shellTool, 'execution.shellTool')
|
|
100
|
+
if (patch.execution.subagentProvider !== undefined) execution.subagentProvider = optionalString(patch.execution.subagentProvider, 'execution.subagentProvider')
|
|
101
|
+
if (patch.execution.approvalTimeoutMs !== undefined) {
|
|
102
|
+
execution.approvalTimeoutMs = requirePositiveInteger(Number(patch.execution.approvalTimeoutMs), 'execution.approvalTimeoutMs', { min: 1000 })
|
|
103
|
+
}
|
|
104
|
+
if (patch.execution.delegateTimeoutMs !== undefined) {
|
|
105
|
+
execution.delegateTimeoutMs = requirePositiveInteger(Number(patch.execution.delegateTimeoutMs), 'execution.delegateTimeoutMs', { min: 1000 })
|
|
106
|
+
}
|
|
107
|
+
clean.execution = execution
|
|
108
|
+
}
|
|
109
|
+
if (patch.policy !== undefined) {
|
|
110
|
+
if (!isPlainObject(patch.policy)) throw Object.assign(new Error('policy must be an object'), { code: 'bad-request' })
|
|
111
|
+
const policy = {}
|
|
112
|
+
if (patch.policy.allowedRoots !== undefined) policy.allowedRoots = stringList(patch.policy.allowedRoots, 'policy.allowedRoots')
|
|
113
|
+
if (patch.policy.allowedTools !== undefined) policy.allowedTools = stringList(patch.policy.allowedTools, 'policy.allowedTools')
|
|
114
|
+
if (patch.policy.approvalTtlMs !== undefined) {
|
|
115
|
+
policy.approvalTtlMs = requirePositiveInteger(Number(patch.policy.approvalTtlMs), 'policy.approvalTtlMs', { min: 1000 })
|
|
116
|
+
}
|
|
117
|
+
if (patch.policy.capabilities !== undefined) {
|
|
118
|
+
if (!isPlainObject(patch.policy.capabilities)) throw Object.assign(new Error('policy.capabilities must be an object'), { code: 'bad-request' })
|
|
119
|
+
const capabilities = {}
|
|
120
|
+
for (const [key, value] of Object.entries(patch.policy.capabilities)) {
|
|
121
|
+
if (!CAPABILITY_MODES.includes(value)) {
|
|
122
|
+
throw Object.assign(new Error(`policy.capabilities.${key} must be auto|ask|deny`), { code: 'bad-request' })
|
|
123
|
+
}
|
|
124
|
+
capabilities[key] = value
|
|
125
|
+
}
|
|
126
|
+
policy.capabilities = capabilities
|
|
127
|
+
}
|
|
128
|
+
clean.policy = policy
|
|
129
|
+
}
|
|
130
|
+
if (patch.approvals !== undefined) {
|
|
131
|
+
if (!isPlainObject(patch.approvals)) throw Object.assign(new Error('approvals must be an object'), { code: 'bad-request' })
|
|
132
|
+
const approvals = {}
|
|
133
|
+
if (patch.approvals.ttlMs !== undefined) approvals.ttlMs = requirePositiveInteger(Number(patch.approvals.ttlMs), 'approvals.ttlMs', { min: 1000 })
|
|
134
|
+
if (patch.approvals.maxPending !== undefined) approvals.maxPending = requirePositiveInteger(Number(patch.approvals.maxPending), 'approvals.maxPending', { min: 1 })
|
|
135
|
+
if (patch.approvals.simulation !== undefined) approvals.simulation = patch.approvals.simulation === true
|
|
136
|
+
clean.approvals = approvals
|
|
137
|
+
}
|
|
138
|
+
return clean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class RuntimeConfig {
|
|
142
|
+
/**
|
|
143
|
+
* @param {object} options
|
|
144
|
+
* @param {object} options.base profile (Cordis) config
|
|
145
|
+
* @param {string} [options.overridesFile] JSON file edited from the UI
|
|
146
|
+
*/
|
|
147
|
+
constructor({ base = {}, overridesFile = null } = {}) {
|
|
148
|
+
this.base = base
|
|
149
|
+
this.overridesFile = overridesFile
|
|
150
|
+
this.overrides = readJson(overridesFile) ?? {}
|
|
151
|
+
this.current = deepMerge(this.base, this.overrides)
|
|
152
|
+
this.applyHandlers = []
|
|
153
|
+
this.updating = Promise.resolve()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onChange(handler) {
|
|
157
|
+
this.applyHandlers.push(handler)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
persist() {
|
|
161
|
+
if (!this.overridesFile) return
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(dirname(this.overridesFile), { recursive: true })
|
|
164
|
+
const temporary = `${this.overridesFile}.tmp`
|
|
165
|
+
writeFileSync(temporary, JSON.stringify(this.overrides, null, 2), 'utf8')
|
|
166
|
+
renameSync(temporary, this.overridesFile)
|
|
167
|
+
} catch {
|
|
168
|
+
/* overrides are best effort; the in-memory config is still applied */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Merge, persist and live-apply one patch. Returns the effective config. */
|
|
173
|
+
update(patch) {
|
|
174
|
+
const clean = sanitizeConfigPatch(patch)
|
|
175
|
+
return this.updating.then(async () => {
|
|
176
|
+
const previous = this.current
|
|
177
|
+
this.overrides = deepMerge(this.overrides, clean)
|
|
178
|
+
this.current = deepMerge(this.base, this.overrides)
|
|
179
|
+
this.persist()
|
|
180
|
+
for (const handler of this.applyHandlers) await handler(this.current, previous)
|
|
181
|
+
return this.current
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
snapshot() {
|
|
186
|
+
return {
|
|
187
|
+
config: this.current,
|
|
188
|
+
overrides: this.overrides,
|
|
189
|
+
readonly: READONLY_FIELDS,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/net/fleet-node.js
CHANGED
|
@@ -489,6 +489,28 @@ export class FleetNode extends EventEmitter {
|
|
|
489
489
|
return [...this.channels.keys()]
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** Apply live network changes without recreating the node or its identity. */
|
|
493
|
+
async reconfigure({ name, direct, discovery, relay } = {}) {
|
|
494
|
+
if (this.closed) return
|
|
495
|
+
if (typeof name === 'string' && name !== '') this.name = name
|
|
496
|
+
const networkChanged = direct !== undefined || discovery !== undefined || relay !== undefined
|
|
497
|
+
if (!networkChanged) return
|
|
498
|
+
const wasStarted = this.started
|
|
499
|
+
await this.close()
|
|
500
|
+
this.closed = false
|
|
501
|
+
this.started = false
|
|
502
|
+
this.hintAttempt = 0
|
|
503
|
+
this.discovered.clear()
|
|
504
|
+
if (direct !== undefined) this.directConfig = direct
|
|
505
|
+
if (discovery !== undefined) this.discoveryConfig = discovery
|
|
506
|
+
if (relay !== undefined) this.relayConfig = relay
|
|
507
|
+
this.directServer = null
|
|
508
|
+
this.discovery = null
|
|
509
|
+
this.relayClient = null
|
|
510
|
+
this.directAddress = null
|
|
511
|
+
if (wasStarted) await this.start()
|
|
512
|
+
}
|
|
513
|
+
|
|
492
514
|
async close() {
|
|
493
515
|
this.closed = true
|
|
494
516
|
clearTimeout(this.hintTimer)
|
package/src/plugin/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the six `fleet_*` tools, and the two adoption mechanisms that are cheap to
|
|
5
5
|
// land now (a prompt section and a first-use manual + orchestration nudge).
|
|
6
6
|
// Room, Room Board, delegation and the UI land in later steps.
|
|
7
|
-
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { networkInterfaces } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
@@ -19,6 +19,8 @@ import { ApprovalInbox } from '../host/approval-inbox.js'
|
|
|
19
19
|
import { RoomService } from '../host/room-service.js'
|
|
20
20
|
import { registerApprovalRoutes } from '../host/approval-web.js'
|
|
21
21
|
import { registerUiRoutes } from '../host/ui-web.js'
|
|
22
|
+
import { registerConfigRoutes } from '../host/config-web.js'
|
|
23
|
+
import { RuntimeConfig } from '../host/runtime-config.js'
|
|
22
24
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
23
25
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
24
26
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
@@ -128,7 +130,7 @@ function withManual(definitions) {
|
|
|
128
130
|
}))
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
export function apply(ctx,
|
|
133
|
+
export function apply(ctx, profileConfig) {
|
|
132
134
|
const pluginVersion = packageVersion()
|
|
133
135
|
const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
|
|
134
136
|
// Optional services: Cordis throws on direct property access for services the
|
|
@@ -144,12 +146,20 @@ export function apply(ctx, config) {
|
|
|
144
146
|
|
|
145
147
|
let state
|
|
146
148
|
try {
|
|
147
|
-
state = loadState({ dshHome: process.env.DSH_HOME, dataDir:
|
|
149
|
+
state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
|
|
148
150
|
} catch (error) {
|
|
149
151
|
logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
|
|
150
152
|
return
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// Runtime config: profile patch is the base, UI edits are persisted in the
|
|
156
|
+
// state dir and applied live (no plugin/DSH restart).
|
|
157
|
+
const runtime = new RuntimeConfig({
|
|
158
|
+
base: profileConfig ?? {},
|
|
159
|
+
overridesFile: join(state.dataDir, 'config-overrides.json'),
|
|
160
|
+
})
|
|
161
|
+
const config = runtime.current
|
|
162
|
+
|
|
153
163
|
const node = new FleetNode({
|
|
154
164
|
identity: state.identity,
|
|
155
165
|
fleet: state.fleet,
|
|
@@ -169,6 +179,8 @@ export function apply(ctx, config) {
|
|
|
169
179
|
const sessionController = service('sessionController')
|
|
170
180
|
const fs = service('fs')
|
|
171
181
|
const subagents = service('subagents')
|
|
182
|
+
const agentPresets = service('agentPresets')
|
|
183
|
+
const workingDirectory = service('workingDirectory')
|
|
172
184
|
const inbox = new ApprovalInbox({
|
|
173
185
|
ttlMs: config.approvals?.ttlMs ?? 60_000,
|
|
174
186
|
maxPending: config.approvals?.maxPending ?? 50,
|
|
@@ -178,26 +190,26 @@ export function apply(ctx, config) {
|
|
|
178
190
|
list: () => inbox.list(),
|
|
179
191
|
approve: (id, options) => inbox.approve(id, options),
|
|
180
192
|
deny: (id, reason) => inbox.deny(id, reason),
|
|
181
|
-
simulate:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
simulate: async ({ fromDeviceId, capability, tool, reason }) => {
|
|
194
|
+
if (runtime.current.approvals?.simulation !== true) {
|
|
195
|
+
throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
|
|
196
|
+
}
|
|
197
|
+
const promise = inbox.request({
|
|
198
|
+
fromDeviceId: fromDeviceId ?? 'simulated-device',
|
|
199
|
+
capability: capability ?? 'run',
|
|
200
|
+
tool: tool ?? null,
|
|
201
|
+
reason: reason ?? 'simulated approval request',
|
|
202
|
+
args: {},
|
|
203
|
+
})
|
|
204
|
+
const summary = inbox.list().at(-1)
|
|
205
|
+
// Keep the promise from becoming an unhandled rejection when the
|
|
206
|
+
// request expires without an answer; the demo only inspects the inbox.
|
|
207
|
+
promise.catch(() => {})
|
|
208
|
+
return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
|
|
209
|
+
},
|
|
197
210
|
}
|
|
198
|
-
const requestApproval =
|
|
199
|
-
|
|
200
|
-
: async (request) => inbox.request(request)
|
|
211
|
+
const requestApproval = async (request) =>
|
|
212
|
+
runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
|
|
201
213
|
|
|
202
214
|
const rooms = new RoomService({
|
|
203
215
|
sessionController,
|
|
@@ -208,8 +220,74 @@ export function apply(ctx, config) {
|
|
|
208
220
|
// Client approval panel data plane (dev.9). Optional service: host-only
|
|
209
221
|
// deployments simply do not expose the panel routes.
|
|
210
222
|
const webServer = service('webServer')
|
|
223
|
+
const workspaceDir = join(state.dataDir, 'workspace')
|
|
224
|
+
const getOptions = async () => {
|
|
225
|
+
const addresses = new Set(['0.0.0.0', '127.0.0.1', runtime.current.direct?.host ?? ''])
|
|
226
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
227
|
+
for (const entry of entries ?? []) {
|
|
228
|
+
if (entry.family === 'IPv4' && !entry.internal) addresses.add(entry.address)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
addresses.delete('')
|
|
232
|
+
let toolNames = []
|
|
233
|
+
try {
|
|
234
|
+
toolNames = (ctx.tools.definitions?.() ?? []).map((definition) => definition.name)
|
|
235
|
+
} catch {
|
|
236
|
+
toolNames = []
|
|
237
|
+
}
|
|
238
|
+
const shellCandidates = ['pwsh', 'bash', 'cmd', 'sh']
|
|
239
|
+
let shells = shellCandidates.filter((name) => toolNames.includes(name))
|
|
240
|
+
if (shells.length === 0) shells = [process.platform === 'win32' ? 'pwsh' : 'bash']
|
|
241
|
+
let presets = []
|
|
242
|
+
try {
|
|
243
|
+
presets = (await agentPresets?.list?.()) ?? []
|
|
244
|
+
} catch {
|
|
245
|
+
presets = []
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
platform: process.platform,
|
|
249
|
+
addresses: [...addresses],
|
|
250
|
+
shells,
|
|
251
|
+
subagentProviders: (() => {
|
|
252
|
+
try {
|
|
253
|
+
return subagents?.list?.() ?? ['spawn']
|
|
254
|
+
} catch {
|
|
255
|
+
return ['spawn']
|
|
256
|
+
}
|
|
257
|
+
})(),
|
|
258
|
+
presets: presets.map((preset) => ({
|
|
259
|
+
id: preset.id ?? preset.name,
|
|
260
|
+
label: preset.name ?? preset.title ?? preset.id,
|
|
261
|
+
})),
|
|
262
|
+
cwd: {
|
|
263
|
+
workspace: workspaceDir,
|
|
264
|
+
dshHome: process.env.DSH_HOME ?? '',
|
|
265
|
+
defaultDirectory: workingDirectory?.defaultDirectory ?? '',
|
|
266
|
+
current: runtime.current.execution?.cwd ?? '',
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
211
270
|
if (webServer) {
|
|
212
271
|
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
272
|
+
ctx.effect(() =>
|
|
273
|
+
registerConfigRoutes({
|
|
274
|
+
webServer,
|
|
275
|
+
runtime,
|
|
276
|
+
getOptions,
|
|
277
|
+
preparePatch: async (patch) => {
|
|
278
|
+
const cwd = patch?.execution?.cwd
|
|
279
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
280
|
+
try {
|
|
281
|
+
mkdirSync(cwd, { recursive: true })
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logger.warn?.(`[device-fleet] cannot create execution cwd: ${String(error?.message ?? error)}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return patch
|
|
287
|
+
},
|
|
288
|
+
logger,
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
213
291
|
ctx.effect(() =>
|
|
214
292
|
registerUiRoutes({
|
|
215
293
|
webServer,
|
|
@@ -317,7 +395,7 @@ export function apply(ctx, config) {
|
|
|
317
395
|
: (state.enrollment?.accountFingerprint ?? null)
|
|
318
396
|
return createInvite(state, {
|
|
319
397
|
accountFingerprint: fingerprint,
|
|
320
|
-
relay:
|
|
398
|
+
relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
|
|
321
399
|
endpoints: localEndpoints(),
|
|
322
400
|
})
|
|
323
401
|
},
|
|
@@ -345,13 +423,14 @@ export function apply(ctx, config) {
|
|
|
345
423
|
|
|
346
424
|
// Adoption layer 2: one sentence, only when the Fleet has members.
|
|
347
425
|
const systemPrompt = service('systemPrompt')
|
|
348
|
-
if (systemPrompt
|
|
426
|
+
if (systemPrompt) {
|
|
349
427
|
const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
|
|
350
428
|
ctx.effect(() =>
|
|
351
429
|
systemPrompt.section({
|
|
352
430
|
name: 'device-fleet:members',
|
|
353
431
|
order,
|
|
354
432
|
text: () => {
|
|
433
|
+
if (runtime.current.promptSection === false) return ''
|
|
355
434
|
const members = node.rosterEntries().length
|
|
356
435
|
if (members === 0) return ''
|
|
357
436
|
return `This DSH instance is part of a device Fleet with ${members} other device(s). Use fleet_list/fleet_read/fleet_fetch/fleet_run for work that belongs on another device; direct calls fail fast when that device is offline.`
|
|
@@ -360,6 +439,48 @@ export function apply(ctx, config) {
|
|
|
360
439
|
)
|
|
361
440
|
}
|
|
362
441
|
|
|
442
|
+
// Live config application: every UI save runs these handlers in-process.
|
|
443
|
+
runtime.onChange(async (next, previous) => {
|
|
444
|
+
if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
|
|
445
|
+
policy = new ToolPolicy(next.policy)
|
|
446
|
+
if (host) host.policy = policy
|
|
447
|
+
if (delegation) delegation.policy = policy
|
|
448
|
+
}
|
|
449
|
+
if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
|
|
450
|
+
slot?.setConfig(next.execution)
|
|
451
|
+
if (host) {
|
|
452
|
+
host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
|
|
453
|
+
host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
|
|
454
|
+
}
|
|
455
|
+
if (delegation) {
|
|
456
|
+
delegation.defaultCwd = next.execution?.cwd
|
|
457
|
+
delegation.defaultAgentPreset = next.execution?.agentPreset
|
|
458
|
+
delegation.provider = next.execution?.subagentProvider ?? 'spawn'
|
|
459
|
+
delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
|
|
463
|
+
if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
|
|
464
|
+
if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
|
|
465
|
+
}
|
|
466
|
+
if (next.deviceName !== previous?.deviceName) {
|
|
467
|
+
node.name = next.deviceName
|
|
468
|
+
if (host) host.name = next.deviceName
|
|
469
|
+
}
|
|
470
|
+
const networkChanged =
|
|
471
|
+
JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
|
|
472
|
+
next.discovery !== previous?.discovery ||
|
|
473
|
+
JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
|
|
474
|
+
if (networkChanged) {
|
|
475
|
+
await node.reconfigure({
|
|
476
|
+
name: next.deviceName,
|
|
477
|
+
direct: next.direct,
|
|
478
|
+
discovery: next.discovery ? true : false,
|
|
479
|
+
relay: next.relay ?? null,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
|
|
363
484
|
// Adoption layer 4: one-line nudge when the model starts orchestrating.
|
|
364
485
|
ctx.on?.('tools/post-execute', async (exec, _result, next) => {
|
|
365
486
|
const decision = await next()
|