@mzzsfy/dsh-shell-select 0.2.0 → 0.3.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.
- package/README.md +3 -2
- package/cordis.patch.yml +18 -0
- package/package.json +3 -2
- package/src/client.js +67 -19
- package/test/client-card.test.mjs +72 -8
- package/test/preset-drift.test.mjs +174 -0
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Windows shell 链接管:禁用官方 `pwsh` 工具与执行器,替换为可配
|
|
|
6
6
|
|
|
7
7
|
- bundle 补丁按 id 禁用官方 `tool-pwsh`、`pwsh-sandbox` 两行,插入本包主行(`ctx.shell` 执行器 + `shell` 工具 + 设置节 + 浏览器设置页路由)与 guard 哨兵行;卸载本包即补丁消失,官方行自动复位。
|
|
8
8
|
- client 半区 `dsh.client.inject` 声明(官方机制,dsh-client-file-upload 等官方包同款):保证 client 运行时模块表中 `primitives`/`slots` 面在场;primitives 另有 try/catch 降级自绘兜底。
|
|
9
|
-
- 本包执行器完整实现官方 `ctx.shell` seam(`resolve`/`run`/`start`/`runFor`/`startFor`/`entryFor`/`sandboxMode`),结构官方 PwshLocalExecutor 同构(实例状态/方法全公有——cordis 服务代理会把经 `ctx.shell` 调用的方法 `this` 重定向到阴影对象,`#` 私有触发品牌检查错误)。
|
|
9
|
+
- 本包执行器完整实现官方 `ctx.shell` seam(`resolve`/`run`/`start`/`runFor`/`startFor`/`entryFor`/`sandboxMode`),结构官方 PwshLocalExecutor 同构(实例状态/方法全公有——cordis 服务代理会把经 `ctx.shell` 调用的方法 `this` 重定向到阴影对象,`#` 私有触发品牌检查错误)。
|
|
10
|
+
- preset 面收口:web 面把 agent 平面后移到每会话 agent preset,内置 standard 预设在 win32 重新声明官方 `pwsh` 工具行,bundle 补丁对 preset 组合树不可达。本包自带预设 `shell-select`(standard 全量副本 − tool-pwsh 行,`presets/shell-select/`),经补丁覆写 `agent-presets` 行接入:win32 默认预设切到本包预设(POSIX 保持 standard,行为与上游一致),`roots` 指向本包 presets 目录;`agent-presets.default` 的 settings 值恒优先于补丁缺省,用户显式选择不被劫持。上游预设漂移由 `test/preset-drift.test.mjs` 逐行守卫,升级后按官方实文对表。
|
|
10
11
|
- 沙箱语义官方同构:`danger-full-access` 直跑;受限模式经 `ctx.sandbox.confine` 包装并按官方方言分类拒绝/runner 失败,权限模型不变。
|
|
11
12
|
- 死态自愈:用户 patch 层禁用主行的窗口内,guard 哨兵(id 含 `/`,市场不写该层)代挂官方 `dsh-tool-pwsh` + `dsh-pwsh-sandbox` 恢复 host 面服务与 boot 组合(预载失败退避重试,至多 3 次),任一方复活先卸代挂。哨兵/复活让位语义逐项同构 `dsh-llm-pi-gateway`。边界:cordis 服务按 fiber 树解析,代挂对 agent preset 作用域不可见,死态窗口新会话的 preset tool-pwsh 行拒挂(报错清晰)属预期。
|
|
12
13
|
- POSIX 上本包全部行停用,官方 bash 链不受影响。
|
|
@@ -59,7 +60,7 @@ env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `e
|
|
|
59
60
|
|
|
60
61
|
- 配置节顶层 `deny`:正则字符串数组,对模型提交的整条命令文本匹配(大小写不敏感),命中即拒绝——**deny 绝对,无豁免语义**;空数组 = 不拦截(出厂默认)。
|
|
61
62
|
- 精细放行在模式内用正则前瞻表达:`deny: ['rm -rf\\s+(?!\\S*node_modules)']` 禁 rm -rf 但放行清依赖目录;默认放行系统下独立 allow 列表的唯一语义就是覆盖 deny,与"deny 绝对"矛盾,故不设。
|
|
62
|
-
- 拦截点在执行器 runFor/startFor 入口:经 `ctx.shell`
|
|
63
|
+
- 拦截点在执行器 runFor/startFor 入口:经 `ctx.shell` 代理的消费方(如 hooks 桥)与 preset 预设切换前的官方 pwsh 工具同样受管。拒绝以模型可见标记返回:`[blocked by shell-select: matches deny pattern …]`,后台任务在启动前同步拒绝,不产生僵尸任务。
|
|
63
64
|
- 定位是防误触护栏而非安全边界:整文本正则挡不住间接包装(编码/嵌套壳),真正的边界始终是访问模式与沙箱。坏正则条目容错跳过,不瘫执行链;设置页为逐条规则编辑器(每条规则独立行,行级即时校验并标错,保存时按行定位拦截非法正则)。
|
|
64
65
|
|
|
65
66
|
### bash 形探测与 msys2
|
package/cordis.patch.yml
CHANGED
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
# 哨兵自愈与复活让位语义逐项同构 packages/dsh-llm-pi-gateway 的 guard/takeover,
|
|
15
15
|
# 差异仅两处:官方代挂对象为 tool-pwsh + pwsh-sandbox 两行;行配置读官方
|
|
16
16
|
# `shell` settings 节(pwsh-sandbox 经其 Config 消费,保持官方 pwshPath 配置)。
|
|
17
|
+
#
|
|
18
|
+
# preset 面:web 面把 agent 平面后移到每会话 agent preset,内置 standard 预设
|
|
19
|
+
# 在 win32 重新声明 tool-pwsh 行,bundle patch 对 preset 组合树不可达。收口方式:
|
|
20
|
+
# 本包自带 preset(standard 全量副本 − tool-pwsh 行,见 presets/shell-select/),
|
|
21
|
+
# 经 agent-presets 行覆写接入——roots 指向本包 presets 目录(default 'user' 承接
|
|
22
|
+
# schema 缺省),default 在 win32 切到本包预设、POSIX 保持 standard(本包行在
|
|
23
|
+
# POSIX 全停用,行为须与上游逐字一致)。路径表达式由 ctx.baseUrl(profile 目录)
|
|
24
|
+
# 推 node_modules/@mzzsfy/dsh-shell-select/presets/:bundle 必经 profile node_modules
|
|
25
|
+
# 解析(resolveBundleDir 顺序),本 patch 在执行即证目录在。default 仅为 settings
|
|
26
|
+
# `agent-presets.default` 缺省(config.default 是其 base),用户显式选择恒优先;
|
|
27
|
+
# 上游行更名/缺行时 patch 告警跳过,行为自然回落 standard。
|
|
17
28
|
|
|
18
29
|
- id: pwsh-sandbox
|
|
19
30
|
disabled: true
|
|
@@ -21,6 +32,13 @@
|
|
|
21
32
|
- id: tool-pwsh
|
|
22
33
|
disabled: true
|
|
23
34
|
|
|
35
|
+
- id: agent-presets
|
|
36
|
+
name: '@deepseek-ai/dsh-agent-presets'
|
|
37
|
+
config:
|
|
38
|
+
default: !!js "process.platform === 'win32' ? 'shell-select' : 'standard'"
|
|
39
|
+
roots:
|
|
40
|
+
- path: !!js decodeURIComponent(new URL('node_modules/@mzzsfy/dsh-shell-select/presets/', ctx.baseUrl).pathname).replace(/^\/([A-Za-z]:)/, '$1')
|
|
41
|
+
|
|
24
42
|
- insert:
|
|
25
43
|
- id: shell-select
|
|
26
44
|
name: '@mzzsfy/dsh-shell-select'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-shell-select",
|
|
3
3
|
"description": "Windows shell 接管:替换默认 pwsh 工具为可配置多客户端的 shell 工具(git bash/WSL/cmd/pwsh,自定义路径,默认客户端,模型按名选择)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/executor.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"@deepseek-ai/dsh-subprocess": "0.1.5-rc.2",
|
|
60
60
|
"@deepseek-ai/dsh-timeout": "0.1.5-rc.2",
|
|
61
61
|
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
62
|
-
"@deepseek-ai/schemastery": "3.18.2"
|
|
62
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
63
|
+
"js-yaml": "4.3.2"
|
|
63
64
|
},
|
|
64
65
|
"files": [
|
|
65
66
|
"src",
|
package/src/client.js
CHANGED
|
@@ -65,6 +65,31 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
return payload
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// 工具卡客户端名册缓存:GET /config 单飞拉取,shell 徽章按 id→用户命名解析。
|
|
69
|
+
// 失败置 null(徽章不渲染),不重试——下次页面加载自然重取
|
|
70
|
+
let clientCatalog = null
|
|
71
|
+
let clientCatalogPromise = null
|
|
72
|
+
// LOGIC-BEGIN ensureClientCatalog
|
|
73
|
+
function ensureClientCatalog() {
|
|
74
|
+
if (clientCatalogPromise === null) {
|
|
75
|
+
clientCatalogPromise = api(API.config).then((section) => {
|
|
76
|
+
const byId = {}
|
|
77
|
+
for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.name
|
|
78
|
+
clientCatalog = { default: section.resolved?.default, byId }
|
|
79
|
+
}).catch(() => { })
|
|
80
|
+
}
|
|
81
|
+
return clientCatalogPromise
|
|
82
|
+
}
|
|
83
|
+
// LOGIC-END ensureClientCatalog
|
|
84
|
+
// 显式 shell 参数按 id 取用户命名;缺省落 default 客户端的用户命名(配置即当前解析事实)
|
|
85
|
+
// LOGIC-BEGIN clientDisplayName
|
|
86
|
+
function clientDisplayName(requested) {
|
|
87
|
+
if (clientCatalog === null) return undefined
|
|
88
|
+
const id = requested !== undefined && requested !== '' ? requested : clientCatalog.default
|
|
89
|
+
return id !== undefined ? clientCatalog.byId[id] : undefined
|
|
90
|
+
}
|
|
91
|
+
// LOGIC-END clientDisplayName
|
|
92
|
+
|
|
68
93
|
const CSS = [
|
|
69
94
|
'.sls-card { display:flex; flex-direction:column; gap:10px; }',
|
|
70
95
|
'.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
|
|
@@ -96,23 +121,26 @@ window.__ModuleLoader__.load({
|
|
|
96
121
|
'.sls-rule--bad .sls-rule__input { border-color:#c44; }',
|
|
97
122
|
'.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
|
98
123
|
'.sls-rule__del { flex:none; padding:2px 8px; }',
|
|
99
|
-
// tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 +
|
|
100
|
-
|
|
124
|
+
// tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强。
|
|
125
|
+
// 色彩对齐官方 ToolRow:标题 label-secondary、icon 盒 label-tertiary(实证自
|
|
126
|
+
// 官方行 computed style);token 缺失时回退 inherit 不致不可读
|
|
127
|
+
'.sls-tv { font-size:13px; line-height:1.45; color:var(--dsw-alias-label-secondary, inherit); }',
|
|
101
128
|
'.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
|
|
102
129
|
'.sls-tv__row--exp { cursor:pointer; user-select:none; }',
|
|
103
|
-
'.sls-tv__lead { display:flex; align-items:center; gap:4px; color:inherit; }',
|
|
130
|
+
'.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
104
131
|
'.sls-tv__chev { opacity:.45; transition:transform .15s ease; }',
|
|
105
132
|
'.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
|
|
106
133
|
'.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
|
|
107
|
-
'.sls-tv__title { font-weight:
|
|
108
|
-
'.sls-tv__sep { width:
|
|
109
|
-
'.sls-tv__sum {
|
|
110
|
-
'.sls-tv__sum--err { color
|
|
134
|
+
'.sls-tv__title { font-weight:400; }',
|
|
135
|
+
'.sls-tv__sep { width:2px; height:2px; border-radius:1px; background:var(--dsw-alias-label-caption, currentColor); opacity:.8; flex:none; }',
|
|
136
|
+
'.sls-tv__sum { color:var(--dsw-alias-label-tertiary, inherit); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
137
|
+
'.sls-tv__sum--err { color:var(--dsw-alias-state-error-primary, #d4553f); }',
|
|
111
138
|
'.sls-tv__body { margin:6px 0 4px; border:1px solid rgba(128,128,128,.28); border-radius:8px; overflow:hidden; }',
|
|
112
139
|
'.sls-tv__head { display:flex; align-items:center; gap:8px; padding:6px 10px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); }',
|
|
113
140
|
'.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; }',
|
|
114
141
|
'.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
|
|
115
142
|
'.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
|
|
143
|
+
'.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
116
144
|
'.sls-tv__sp { flex:1; }',
|
|
117
145
|
'.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
|
|
118
146
|
'.sls-tv__copyBtn { padding:2px 8px; border:1px solid rgba(128,128,128,.35); border-radius:6px; background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
|
|
@@ -592,27 +620,31 @@ window.__ModuleLoader__.load({
|
|
|
592
620
|
} catch { /* 结构外形态走 generic */ }
|
|
593
621
|
const command = typeof args?.command === 'string' && args.command.trim() !== '' ? args.command : ''
|
|
594
622
|
const description = typeof args?.description === 'string' && args.description.trim() !== '' ? args.description : undefined
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const shellName = typeof args?.shell === 'string' && args.shell !== '' ? args.shell : undefined
|
|
623
|
+
const cwdFull = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
|
|
624
|
+
const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
|
|
625
|
+
// shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
|
|
626
|
+
const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
|
|
600
627
|
if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
|
|
601
628
|
|
|
602
629
|
if (!settled) {
|
|
603
630
|
// running persistent(进行中无描述):回退行但状态点用进行中灰,非 warning 黄
|
|
604
631
|
if (description === undefined) return { kind: 'generic', command, summary: undefined, output: null, running: true }
|
|
605
|
-
return { kind: 'terminal', status: 'running', command, description, cwdDir, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
|
|
632
|
+
return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
|
|
606
633
|
}
|
|
607
634
|
|
|
608
635
|
const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
|
|
636
|
+
// 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
|
|
637
|
+
if (args.run_in_background === true) {
|
|
638
|
+
const jobId = /started background job (\S+)/.exec(contentText)?.[1]
|
|
639
|
+
return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
|
|
640
|
+
}
|
|
609
641
|
// 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡
|
|
610
|
-
if (contentText === '' || block.isError === true || block.error !== undefined ||
|
|
642
|
+
if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
|
|
611
643
|
return { kind: 'generic', command, summary: description, output: contentText }
|
|
612
644
|
}
|
|
613
645
|
const tail = parseExitTail(contentText)
|
|
614
646
|
const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
|
|
615
|
-
return { kind: 'terminal', status, command, description, cwdDir, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
|
|
647
|
+
return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
|
|
616
648
|
}
|
|
617
649
|
// LOGIC-END shellCardModel
|
|
618
650
|
|
|
@@ -627,6 +659,7 @@ window.__ModuleLoader__.load({
|
|
|
627
659
|
function statusTextOf(status, en) {
|
|
628
660
|
switch (status) {
|
|
629
661
|
case 'running': return en ? 'Running' : '运行中'
|
|
662
|
+
case 'background': return en ? 'Background' : '后台'
|
|
630
663
|
case 'failed': case 'signaled': return en ? 'Failed' : '失败'
|
|
631
664
|
default: return null
|
|
632
665
|
}
|
|
@@ -635,6 +668,13 @@ window.__ModuleLoader__.load({
|
|
|
635
668
|
function headMetaOf(model, en) {
|
|
636
669
|
switch (model.status) {
|
|
637
670
|
case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
|
|
671
|
+
// ack 块静态不反映 job 生命周期,状态点用中性工具图标,文案不带"运行中"
|
|
672
|
+
case 'background': return {
|
|
673
|
+
dot: 'none',
|
|
674
|
+
label: en ? 'Background' : '后台',
|
|
675
|
+
pill: model.jobId !== undefined ? (en ? `bg ${model.jobId}` : `后台 ${model.jobId}`) : undefined,
|
|
676
|
+
pillTone: 'bg',
|
|
677
|
+
}
|
|
638
678
|
case 'done': return { dot: 'done', label: en ? 'Done' : '已完成', pill: undefined }
|
|
639
679
|
case 'failed': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `exit ${model.exitCode}` : `退出码 ${model.exitCode}` }
|
|
640
680
|
case 'signaled': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `signal ${model.signal}` : `信号 ${model.signal}` }
|
|
@@ -715,10 +755,10 @@ window.__ModuleLoader__.load({
|
|
|
715
755
|
TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
|
|
716
756
|
h('span', { className: 'sls-tv__sr' }, meta.label),
|
|
717
757
|
),
|
|
718
|
-
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd' }, model.cwdDir) : null,
|
|
719
|
-
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge' }, model.shellName) : null,
|
|
758
|
+
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
|
|
759
|
+
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
|
|
720
760
|
h('span', { className: 'sls-tv__sp' }),
|
|
721
|
-
meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' }, meta.pill) : null,
|
|
761
|
+
meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, meta.pill) : null,
|
|
722
762
|
h('span', { className: 'sls-tv__copy' },
|
|
723
763
|
h(CopyButton, {
|
|
724
764
|
label: copyCommandLabel,
|
|
@@ -744,7 +784,12 @@ window.__ModuleLoader__.load({
|
|
|
744
784
|
const { block, cwd, inspect } = props
|
|
745
785
|
const en = detectEnglish()
|
|
746
786
|
const [open, setOpen] = useState(false)
|
|
747
|
-
const
|
|
787
|
+
const [, setCatalogReady] = useState(false)
|
|
788
|
+
useEffect(() => {
|
|
789
|
+
let disposed = false
|
|
790
|
+
ensureClientCatalog().then(() => { if (!disposed) setCatalogReady(true) })
|
|
791
|
+
return () => { disposed = true }
|
|
792
|
+
}, [])
|
|
748
793
|
if (model.kind === 'generic') return h(GenericShellRow, { model, inspect, en })
|
|
749
794
|
const expandable = true
|
|
750
795
|
const srStatus = statusTextOf(model.status, en)
|
|
@@ -773,6 +818,9 @@ window.__ModuleLoader__.load({
|
|
|
773
818
|
h('span', { className: 'sls-tv__title' }, 'Shell'),
|
|
774
819
|
h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
|
|
775
820
|
h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
|
|
821
|
+
model.status === 'background' && model.jobId !== undefined
|
|
822
|
+
? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
|
|
823
|
+
: null,
|
|
776
824
|
),
|
|
777
825
|
open ? h(ShellCard, { model, inspect, en }) : null,
|
|
778
826
|
)
|
|
@@ -18,10 +18,16 @@ function extractLogic(name, deps = {}) {
|
|
|
18
18
|
return new Function(...keys, 'return (' + match[1].trim() + ')')(...keys.map((key) => deps[key]))
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function cardModel() {
|
|
21
|
+
function cardModel(catalog = null) {
|
|
22
22
|
const lastSegment = extractLogic('lastSegment')
|
|
23
23
|
const displayCwd = extractLogic('displayCwd', { lastSegment })
|
|
24
|
-
return extractLogic('shellCardModel', {
|
|
24
|
+
return extractLogic('shellCardModel', {
|
|
25
|
+
displayCwd,
|
|
26
|
+
lastSegment,
|
|
27
|
+
parseExitTail: extractLogic('parseExitTail'),
|
|
28
|
+
hasSpillNotice: extractLogic('hasSpillNotice'),
|
|
29
|
+
clientDisplayName: extractLogic('clientDisplayName', { clientCatalog: catalog }),
|
|
30
|
+
})
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
function parseEnvText() {
|
|
@@ -77,14 +83,72 @@ test('S12b running 完整参数:terminal running 卡', () => {
|
|
|
77
83
|
assert.equal(model.status, 'running')
|
|
78
84
|
})
|
|
79
85
|
|
|
80
|
-
test('
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
SESSION_CWD
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
+
test('S15 shell 徽章命名:显式参数按 id 取用户命名,缺省落 default 客户端命名', () => {
|
|
87
|
+
const catalog = { default: 'git-bash', byId: { 'git-bash': 'Git Bash', pwsh: 'PowerShell' } }
|
|
88
|
+
const explicit = cardModel(catalog)(
|
|
89
|
+
settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'pwsh' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
90
|
+
assert.equal(explicit.shellName, 'PowerShell')
|
|
91
|
+
const fallback = cardModel(catalog)(
|
|
92
|
+
settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD)
|
|
93
|
+
assert.equal(fallback.shellName, 'Git Bash')
|
|
94
|
+
const unknown = cardModel(catalog)(
|
|
95
|
+
settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'ghost' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
96
|
+
assert.equal(unknown.shellName, undefined)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('S15b cwd 悬浮全路径:model 携带 cwdFull', () => {
|
|
100
|
+
const model = cardModel()(
|
|
101
|
+
settledBlock(JSON.stringify({ command: 'git status', description: 'Show working tree status', workdir: 'sub' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
102
|
+
assert.equal(model.cwdDir, 'sub')
|
|
103
|
+
assert.equal(model.cwdFull, 'C:\\repo\\sub')
|
|
104
|
+
const noWorkdir = cardModel()(
|
|
105
|
+
settledBlock(JSON.stringify({ command: 'git status', description: 'Show working tree status' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
106
|
+
assert.equal(noWorkdir.cwdFull, SESSION_CWD)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('S16 cwd 悬浮说明:可见文本为 basename,title 为 pwd: + 全路径(渲染守卫)', () => {
|
|
110
|
+
// BDD:Given shell 调用带 workdir,When 悬浮 cwd 元数据,Then 可见文本为 basename,title 为 'pwd: <全路径>' 单段说明
|
|
111
|
+
assert.match(source, /title: 'pwd: ' \+ \(model\.cwdFull \?\? model\.cwdDir\)/)
|
|
112
|
+
assert.match(source, /\}, model\.cwdDir\)/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('S17 后台 ack:派生 background 卡,命令在场且解析出 jobId', () => {
|
|
116
|
+
// BDD:Given run_in_background 调用已定,When 派生卡片模型,Then kind=background,jobId 取自 ack 标记,命令与 ack 原文保留
|
|
117
|
+
const model = cardModel()(settledBlock(
|
|
118
|
+
JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true }),
|
|
119
|
+
'started background job shell-11'), SESSION_CWD)
|
|
120
|
+
assert.equal(model.kind, 'background')
|
|
121
|
+
assert.equal(model.jobId, 'shell-11')
|
|
122
|
+
assert.equal(model.command, 'node server.js')
|
|
123
|
+
assert.equal(model.output, 'started background job shell-11')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('S17b 后台 ack 异常文本:仍 background,jobId 缺省不冒充', () => {
|
|
127
|
+
const model = cardModel()(settledBlock(
|
|
128
|
+
JSON.stringify({ command: 'x', description: 'd', run_in_background: true }), 'other'), SESSION_CWD)
|
|
129
|
+
assert.equal(model.kind, 'background')
|
|
130
|
+
assert.equal(model.jobId, undefined)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('S17c 后台行默认收起,收起行携带任务号徽标(渲染守卫)', () => {
|
|
134
|
+
// BDD:Given 后台 ack 卡,When 行渲染,Then 默认收起与其余行一致,收起态可见 job id 徽标
|
|
135
|
+
assert.match(source, /case 'background':/)
|
|
136
|
+
assert.match(source, /sls-tv__pill--bg/)
|
|
137
|
+
assert.doesNotMatch(source, /useState\(model\.kind === 'background'\)/)
|
|
138
|
+
assert.match(source, /model\.status === 'background' && model\.jobId !== undefined/)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('S17d 后台状态中性:不断言运行中,点为中性图标(渲染守卫)', () => {
|
|
142
|
+
// BDD:Given ack 块静态且不反映 job 生命周期,When 渲染后台状态,Then 文案与点均不断言 ongoing
|
|
143
|
+
assert.doesNotMatch(source, /case 'background':\s*\r?\n\s*dot: 'ongoing'/)
|
|
144
|
+
assert.doesNotMatch(source, /case 'background': return en \? 'Background' : '后台运行'/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('S12c isError 与空结果仍走 generic(回归)', () => {
|
|
86
148
|
const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
|
|
87
149
|
assert.equal(errored.kind, 'generic')
|
|
150
|
+
const empty = cardModel()(settledBlock(ARGS, ''), SESSION_CWD)
|
|
151
|
+
assert.equal(empty.kind, 'generic')
|
|
88
152
|
})
|
|
89
153
|
|
|
90
154
|
test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () => {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// preset 接管守卫:本包自带预设(standard 副本 − tool-pwsh 行)与 bundle patch
|
|
2
|
+
// 的 agent-presets 覆写形态。漂移守卫对照已安装 dsh 的内置 standard 预设——
|
|
3
|
+
// 上游增删行/改表达式即红,升级后按官方实文对表镜像;宿主缺失(无 dsh 本体的
|
|
4
|
+
// 最小环境)时 skip,不制造假红。
|
|
5
|
+
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync, existsSync, mkdirSync, symlinkSync, rmSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { execPath } from 'node:process'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const pkgRoot = join(here, '..')
|
|
17
|
+
const presetText = readFileSync(join(pkgRoot, 'presets', 'shell-select', 'agent.cordis.yml'), 'utf8')
|
|
18
|
+
const metaText = readFileSync(join(pkgRoot, 'presets', 'shell-select', 'preset.yml'), 'utf8')
|
|
19
|
+
const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
|
|
20
|
+
|
|
21
|
+
// ── 宿主侧 yaml 能力探测:js-yaml 与官方 entryListSchema 经 dsh 安装位解析 ──
|
|
22
|
+
|
|
23
|
+
function locateHarnessDir() {
|
|
24
|
+
// 候选锚:运行中 node 的全局安装位(nvm/全局布局:execPath 目录/node_modules)→
|
|
25
|
+
// 仓库 compat 隔离宿主(.dsh-versions/<v>/package)。缺失即返回 null(skip)。
|
|
26
|
+
const candidates = [
|
|
27
|
+
resolve(dirname(execPath), 'node_modules', '@deepseek-ai', 'dsh'),
|
|
28
|
+
]
|
|
29
|
+
const versionsDir = resolve(pkgRoot, '..', '..', '.dsh-versions')
|
|
30
|
+
try {
|
|
31
|
+
for (const version of readdirSync(versionsDir)) {
|
|
32
|
+
candidates.push(join(versionsDir, version, 'package'))
|
|
33
|
+
}
|
|
34
|
+
} catch { /* 无 .dsh-versions,忽略 */ }
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// yaml 能力与宿主解耦:js-yaml 为 devDep(CI 无宿主也可解析本包文件)。
|
|
42
|
+
// !!js 标签本地同形 Type:tag 必须写完整 URI(tag:yaml.org,2002:js)——文档简写
|
|
43
|
+
// !!js 展开后按完整 URI 匹配,字面 '!!js' 永不命中(unknown tag,CI 假绿教训)。
|
|
44
|
+
// 形态场景恒走 LOCAL_SCHEMA(本机/CI 同路径);漂移守卫在场时改用官方
|
|
45
|
+
// entryListSchema 求同(对照面只在有宿主时跑)
|
|
46
|
+
import yamlStatic from 'js-yaml'
|
|
47
|
+
|
|
48
|
+
const LOCAL_SCHEMA = yamlStatic.JSON_SCHEMA.extend(new yamlStatic.Type('tag:yaml.org,2002:js', {
|
|
49
|
+
kind: 'scalar',
|
|
50
|
+
resolve: (data) => typeof data === 'string',
|
|
51
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
52
|
+
represent: (data) => data['__jsExpr'],
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
let harness = null
|
|
56
|
+
let entryListSchema = LOCAL_SCHEMA
|
|
57
|
+
try {
|
|
58
|
+
harness = locateHarnessDir()
|
|
59
|
+
if (harness !== null) {
|
|
60
|
+
const req = createRequire(join(harness, 'package.json'))
|
|
61
|
+
const toUrl = (specifier) => pathToFileURL(req.resolve(specifier)).href
|
|
62
|
+
entryListSchema = (await import(toUrl('@deepseek-ai/cordis-plugin-include'))).entryListSchema
|
|
63
|
+
}
|
|
64
|
+
} catch { /* 官方 schema 缺失按本地同形标签处理 */ }
|
|
65
|
+
|
|
66
|
+
const loadYaml = (text) => yamlStatic.load(text, { schema: entryListSchema })
|
|
67
|
+
|
|
68
|
+
// 镜像 loader 的表达式求值形态(new Function + with(ctx),全局可用)
|
|
69
|
+
const evalExpr = (expr, ctx) => new Function('ctx', `with (ctx) { return (${expr}) }`)(ctx)
|
|
70
|
+
|
|
71
|
+
// ── 结构对比:展平行树为 id → {name, disabled, config 键集} ──
|
|
72
|
+
|
|
73
|
+
function flattenRows(rows, out = new Map()) {
|
|
74
|
+
for (const row of rows ?? []) {
|
|
75
|
+
if (row.id !== undefined) {
|
|
76
|
+
out.set(row.id, {
|
|
77
|
+
name: row.name,
|
|
78
|
+
disabled: row.disabled === undefined ? undefined : row.disabled.__jsExpr ?? row.disabled,
|
|
79
|
+
configKeys: row.config && !Array.isArray(row.config) ? Object.keys(row.config).sort() : undefined,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
if (row.group && Array.isArray(row.config)) flattenRows(row.config, out)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 场景 ──
|
|
88
|
+
|
|
89
|
+
test('预设形态:preset.yml 元数据在场', () => {
|
|
90
|
+
const meta = metaText.trim().split('\n').map((line) => line.split(/:(.*)/s)[0])
|
|
91
|
+
assert.ok(meta.includes('name'), 'preset.yml 缺 name')
|
|
92
|
+
assert.ok(metaText.includes('description:'), 'preset.yml 缺 description')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('预设形态:agent.cordis.yml 可解析且无 tool-pwsh 行', () => {
|
|
96
|
+
// 形态断言恒走本地同形 schema:本机与 CI 同一路径,官方 schema 在场与否不影响本场景
|
|
97
|
+
const rows = yamlStatic.load(presetText, { schema: LOCAL_SCHEMA })
|
|
98
|
+
assert.ok(Array.isArray(rows), '预设组合必须是行数组')
|
|
99
|
+
const ids = new Set()
|
|
100
|
+
const walk = (list) => {
|
|
101
|
+
for (const row of list) {
|
|
102
|
+
if (row.id !== undefined) ids.add(row.id)
|
|
103
|
+
if (row.group && Array.isArray(row.config)) walk(row.config)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
walk(rows)
|
|
107
|
+
assert.ok(ids.has('tool-bash'), 'POSIX bash 工具行必须保留')
|
|
108
|
+
assert.ok(!ids.has('tool-pwsh'), 'tool-pwsh 行必须移除(win32 shell 面由本包接管)')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('漂移守卫:与已安装 standard 逐行对表(缺失宿主则 skip)', (t) => {
|
|
112
|
+
if (harness === null) return t.skip('未找到 dsh 本体安装,无对照源')
|
|
113
|
+
const standardPath = join(harness, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml')
|
|
114
|
+
if (!existsSync(standardPath)) return t.skip('dsh 安装位无 standard 预设,无对照源')
|
|
115
|
+
const standard = flattenRows(loadYaml(readFileSync(standardPath, 'utf8')))
|
|
116
|
+
const ours = flattenRows(loadYaml(presetText))
|
|
117
|
+
for (const [id, row] of standard) {
|
|
118
|
+
if (id === 'tool-pwsh') {
|
|
119
|
+
assert.ok(!ours.has(id), 'standard 有 tool-pwsh 而本包预设也出现了:镜像被污染')
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
assert.ok(ours.has(id), `standard 行 ${id} 在本包预设缺失,须对表镜像`)
|
|
123
|
+
assert.deepEqual(ours.get(id), row, `行 ${id} 与 standard 漂移,升级后按官方实文对表`)
|
|
124
|
+
}
|
|
125
|
+
for (const id of ours.keys()) {
|
|
126
|
+
assert.ok(standard.has(id), `本包预设多出 standard 没有的行 ${id}:镜像越界`)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('schema 同构:本地 !!js Type 与官方 entryListSchema 产物 deepEqual(缺失宿主则 skip)', (t) => {
|
|
131
|
+
if (harness === null) return t.skip('未找到 dsh 本体安装,无官方 schema 对照')
|
|
132
|
+
// harness 命中时 entryListSchema 已是官方版:loadYaml=官方路径,与 LOCAL_SCHEMA 逐文本对照。
|
|
133
|
+
// 本地同形标签的语义漂移曾以 unknown tag 形态炸穿 CI,此处固化为机器断言
|
|
134
|
+
for (const [label, text] of [['预设组合', presetText], ['bundle patch', patchText]]) {
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
yamlStatic.load(text, { schema: LOCAL_SCHEMA }),
|
|
137
|
+
loadYaml(text),
|
|
138
|
+
`${label} 双 schema 产物漂移,本地 !!js Type 须与官方 JsExpr 同构`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('patch 形态:agent-presets 覆写带 name 防御且含 default 与 roots', () => {
|
|
144
|
+
const patch = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
145
|
+
const row = patch.find((entry) => entry.id === 'agent-presets')
|
|
146
|
+
assert.ok(row, 'patch 缺 agent-presets 覆写行')
|
|
147
|
+
assert.equal(row.name, '@deepseek-ai/dsh-agent-presets', 'name 字段是官方行改名防御,必须钉住包名')
|
|
148
|
+
assert.match(row.config.default.__jsExpr, /'shell-select'/)
|
|
149
|
+
assert.match(row.config.default.__jsExpr, /'standard'/)
|
|
150
|
+
assert.equal(row.config.roots.length, 1)
|
|
151
|
+
assert.match(row.config.roots[0].path.__jsExpr, /ctx\.baseUrl/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('patch 表达式求值:default 按平台切换,roots 指向本包 presets 目录', () => {
|
|
155
|
+
const patch = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
156
|
+
const config = patch.find((entry) => entry.id === 'agent-presets').config
|
|
157
|
+
const expectedDefault = process.platform === 'win32' ? 'shell-select' : 'standard'
|
|
158
|
+
assert.equal(evalExpr(config.default.__jsExpr, { baseUrl: 'file:///any/profile/dir/' }), expectedDefault)
|
|
159
|
+
// 合成 profile 布局:baseUrl 锚 <profile>/profiles/web,其下 node_modules/@mzzsfy/dsh-shell-select
|
|
160
|
+
// 为符号链接桥(自依赖桥同构;junction 穿透到真实包目录)
|
|
161
|
+
const fakeProfile = join(tmpdir(), 'shell-select-preset-probe-' + process.pid)
|
|
162
|
+
const linkDir = join(fakeProfile, 'profiles', 'web', 'node_modules', '@mzzsfy')
|
|
163
|
+
mkdirSync(linkDir, { recursive: true })
|
|
164
|
+
symlinkSync(pkgRoot, join(linkDir, 'dsh-shell-select'), 'junction')
|
|
165
|
+
try {
|
|
166
|
+
const evaluated = evalExpr(config.roots[0].path.__jsExpr, {
|
|
167
|
+
baseUrl: pathToFileURL(join(fakeProfile, 'profiles', 'web') + '/').href,
|
|
168
|
+
})
|
|
169
|
+
assert.ok(/presets\/$/.test(evaluated), '求值路径必须锚在包内 presets 目录: ' + evaluated)
|
|
170
|
+
assert.ok(existsSync(join(evaluated, 'shell-select', 'agent.cordis.yml')), '求值路径必须落在本包 presets 目录(符号链接桥穿透)')
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(fakeProfile, { recursive: true, force: true })
|
|
173
|
+
}
|
|
174
|
+
})
|