dsh-remote-agent 0.8.0 → 0.9.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.
@@ -82,6 +82,9 @@
82
82
  import fs from 'node:fs'
83
83
  import path from 'node:path'
84
84
  import { ERROR_CODE, RemoteAgentError, TASK_MODE } from '../protocol.js'
85
+ // 分模式默认超时(提案 #26-1):与 remote_task 工具同源常量。
86
+ // 面板派单也必须按 mode 取值,否则 exec 会静默吃 1 小时超时(2026-10-17 修)。
87
+ import { AGENT_DEFAULT_TIMEOUT_MS, EXEC_DEFAULT_TIMEOUT_MS } from '../node/task-engine.js'
85
88
  import { isInside, normalizePath } from '../glob.js'
86
89
  // 副机接入(pair-info):复用向导的同一份纯逻辑 —— 面板生成的配对码与
87
90
  // dsh-remote-setup 生成的一字不差,避免两套实现漂移。
@@ -97,6 +100,10 @@ import {
97
100
  // 任务模板库(配方):模板是"派发表单的预设值",存 hub 本地 JSON。
98
101
  // 校验/原子写/并发串行化都在那个模块里(本文件只做路由与围栏)。
99
102
  import { createTemplateStore, defaultTemplatesFile } from './templates.js'
103
+ // 环境体检(2026-10-17):与命令行 `dsh-remote-setup --doctor` 共用同一份检查逻辑。
104
+ // 存在的理由是那个最贵的静默失败 —— webserver 绑 loopback 时副机永远连不上,
105
+ // 而主机侧毫无异常。面板必须把它渲染出来。
106
+ import { envChecks } from '../env-check.js'
100
107
 
101
108
  export const UI_API_PREFIX = '/api/remote-agent/ui'
102
109
 
@@ -299,6 +306,10 @@ export function registerUiRoutes({
299
306
  pullRoot,
300
307
  uiTemplatesFile,
301
308
  config = {},
309
+ // 可达性自检结论(2026-10-17):{ bound, origin, loopback, hint }
310
+ // 绑 loopback 时副机永远连不上,而这个失败在主机侧完全静默 ——
311
+ // 面板必须渲染成红色横幅,否则用户只能去猜"地址填错了?token 错了?"
312
+ reachability = null,
302
313
  }) {
303
314
  const disposers = []
304
315
  const guard = makeGuard(ctx, log)
@@ -424,6 +435,16 @@ export function registerUiRoutes({
424
435
  // 面板据此判断"该不该显示落地目录",空串会渲染成 "落地到 " 这种残缺文案。
425
436
  pullRoot: typeof pullRoot === 'string' && pullRoot !== '' ? pullRoot : null,
426
437
  dispatch: true,
438
+ // 可达性自检(2026-10-17):`loopback === true` 时副机永远连不上,
439
+ // 面板渲染红色横幅。逐字段投影,不 spread。
440
+ reachability: reachability
441
+ ? {
442
+ bound: typeof reachability.bound === 'string' ? reachability.bound : null,
443
+ origin: typeof reachability.origin === 'string' ? reachability.origin : null,
444
+ loopback: reachability.loopback === true,
445
+ hint: typeof reachability.hint === 'string' ? reachability.hint : '',
446
+ }
447
+ : null,
427
448
  },
428
449
  })
429
450
  })
@@ -494,7 +515,17 @@ export function registerUiRoutes({
494
515
  model: body.model,
495
516
  outputs: Array.isArray(body.outputs) ? body.outputs.filter((s) => typeof s === 'string') : [],
496
517
  constraints: {
497
- timeoutMs: Number.isFinite(body.timeoutMs) ? body.timeoutMs : (defaults.timeoutMs ?? 3600000),
518
+ // 提案 #26-1(2026-10-17 修第三处):面板派单也必须按 mode 分模式取默认。
519
+ //
520
+ // 原实现 `defaults.timeoutMs ?? 3600000` 让面板派 exec 任务也吃 1 小时
521
+ // 超时 —— 与 remote_task 工具同样的缺陷(exec 该是 10 分钟)。
522
+ // 三处同族失效点:tools.js(TDZ + 兜底遮蔽)、hub/index.js(1h 兜底)、
523
+ // 这里(1h 兜底)。
524
+ timeoutMs: Number.isFinite(body.timeoutMs)
525
+ ? body.timeoutMs
526
+ : (Number.isFinite(defaults.timeoutMs)
527
+ ? defaults.timeoutMs
528
+ : (mode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS)),
498
529
  denyPaths: [],
499
530
  },
500
531
  origin: 'ui',
@@ -631,6 +662,131 @@ export function registerUiRoutes({
631
662
  writeJson(res, 200, { ok: true, config: configView() })
632
663
  })
633
664
 
665
+ // ── 环境体检 ──────────────────────────────────────────────────
666
+ //
667
+ // 与命令行 `dsh-remote-setup --doctor` 共用 `lib/env-check.js` 的同一份逻辑。
668
+ //
669
+ // 为什么面板上必须有这一屏(2026-10-17 实测):那个最贵的静默失败是
670
+ // "webserver 绑 127.0.0.1 → 副机永远连不上,而主机侧毫无异常"。日志用户
671
+ // 不会看,面板会看。所以体检结果要能一键看到,且每条都带修法。
672
+ //
673
+ // 探测项里有一部分需要**真实系统信息**(端口占用、防火墙、Tailscale),
674
+ // 这里只传能拿到的:reachability 来自 applyHub 的自检、nodesOnline 来自
675
+ // registry。其余留给调用方(命令行)补 —— 拿不到的项 envChecks 会跳过,
676
+ // 不会误报。
677
+ route(`${UI_API_PREFIX}/env-check`, (req, res) => {
678
+ if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
679
+ let result
680
+ try {
681
+ const nodes = registry.list()
682
+ result = envChecks({
683
+ role: typeof config.role === 'string' ? config.role : 'hub',
684
+ config,
685
+ reachability,
686
+ nodesOnline: nodes.filter((n) => n.online).length,
687
+ })
688
+ } catch (err) {
689
+ // 体检自身出错不能让面板白屏 —— 回一个"体检失败"而不是 500
690
+ log('warn', 'env-check threw', { error: shortMessage(err) })
691
+ return writeJson(res, 200, {
692
+ ok: false,
693
+ items: [{ ok: false, id: 'internal', label: '体检自身出错', fix: '', detail: shortMessage(err), autoFix: false, severity: 'error' }],
694
+ failed: 1,
695
+ autoFixable: 0,
696
+ })
697
+ }
698
+ writeJson(res, 200, result)
699
+ })
700
+
701
+ // ── 工作区 SMB 共享(2026-10-17)──────────────────────────────
702
+ //
703
+ // 用户需求:想用本地 IDE 直接打开副机的项目目录 —— 唯一现实路径是把工作区
704
+ // 通过 SMB 共享出去,主机 `net use X: \\副机\dsh-work` 映射成盘符。
705
+ //
706
+ // ⚠ **这是本插件唯一主动放宽安全边界的功能**。开启后:
707
+ // · roots / denyPaths 全部失效(Windows 内核直读,插件拦不住)
708
+ // · 「清单先于字节」失效(.env / .git 密钥 / 中间产物全部可见)
709
+ // · 新增凭证面(主机需要副机 Windows 账号)
710
+ //
711
+ // 所以:
712
+ // · `POST /share` 必须带 `confirm: 'yes'` —— 二次确认,缺了直接 400
713
+ // · `access` 默认 `read`(最小权限),写权限要显式传 `write`
714
+ // · **本路由不执行任何共享命令** —— 只返回可复制的命令。执行需要管理员,
715
+ // 而 DSH 通常以普通用户跑;Node 里提权失败比让用户手动跑一条难排查得多
716
+ //
717
+ // 三个 action:
718
+ // GET /share?node=X → probe(当前是否管理员 + 已存在的共享)
719
+ // POST /share { action:'plan', ... } → 生成共享命令 + 主机映射命令
720
+ // POST /share { action:'unshare', ... } → 生成卸载命令
721
+ route(`${UI_API_PREFIX}/share`, async (req, res) => {
722
+ // GET = 只读探测(不需要确认)
723
+ if (req.method === 'GET') {
724
+ const url = new URL(req.url, 'http://localhost')
725
+ const nodeId = (url.searchParams.get('node') ?? '').trim()
726
+ if (nodeId === '') return writeJson(res, 400, { error: 'node-required' })
727
+ try {
728
+ const result = await registry.shareRequest(nodeId, 'probe', {})
729
+ return writeJson(res, 200, { ok: true, ...result })
730
+ } catch (err) {
731
+ return writeJson(res, 200, {
732
+ ok: false,
733
+ code: err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL,
734
+ message: shortMessage(err),
735
+ })
736
+ }
737
+ }
738
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
739
+
740
+ const body = await readJson(req)
741
+ const action = typeof body?.action === 'string' ? body.action : ''
742
+ const nodeId = typeof body?.node === 'string' ? body.node.trim() : ''
743
+ if (nodeId === '') return writeJson(res, 400, { error: 'node-required' })
744
+
745
+ // ⚠ 二次确认是**硬要求**:这是唯一会放宽安全边界的操作,
746
+ // 客户端必须显式表达"我知道风险"。缺了就拒,不给默认通过。
747
+ if (body?.confirm !== 'yes') {
748
+ return writeJson(res, 400, {
749
+ error: 'confirm-required',
750
+ message: '开启工作区共享会让主机绕过本插件的 roots/denyPaths 边界直接读写这些文件。'
751
+ + '请在请求里带 confirm: "yes" 表示已知情。',
752
+ })
753
+ }
754
+
755
+ try {
756
+ if (action === 'plan') {
757
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
758
+ const access = body.access === 'write' ? 'write' : 'read'
759
+ // 主机侧地址:面板把 window.location.hostname 传进来(就是主机自己的地址),
760
+ // 但副机要连的是**主机**,而映射是主机连**副机** —— 所以这里要的是副机地址。
761
+ // 由面板从节点信息里取(registry 有),不信任客户端传的值。
762
+ const node = registry.list().find((n) => n.nodeId === nodeId)
763
+ const hostForMount = typeof body.host === 'string' && body.host !== ''
764
+ ? body.host
765
+ : (node && typeof node.hostname === 'string' ? node.hostname : nodeId)
766
+ const result = await registry.shareRequest(nodeId, 'plan', {
767
+ dir,
768
+ access,
769
+ host: hostForMount,
770
+ drive: typeof body.drive === 'string' ? body.drive : 'X:',
771
+ })
772
+ return writeJson(res, 200, { ok: true, ...result })
773
+ }
774
+ if (action === 'unshare') {
775
+ const shareName = typeof body.shareName === 'string' ? body.shareName.trim() : ''
776
+ if (shareName === '') return writeJson(res, 400, { error: 'shareName-required' })
777
+ const result = await registry.shareRequest(nodeId, 'unshare-plan', { shareName })
778
+ return writeJson(res, 200, { ok: true, ...result })
779
+ }
780
+ return writeJson(res, 400, { error: 'bad-action', message: 'action 必须是 plan / unshare' })
781
+ } catch (err) {
782
+ return writeJson(res, 200, {
783
+ ok: false,
784
+ code: err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL,
785
+ message: shortMessage(err),
786
+ })
787
+ }
788
+ })
789
+
634
790
  // ── 副机接入:生成配对码(面板 GUI 化的落点)──────────────────
635
791
  //
636
792
  // 端口不查内部状态:面板本身就由 hub 的 webserver 服务,浏览器侧把
package/lib/index.js CHANGED
@@ -24,7 +24,7 @@ import { applyHub, newNodeToken, normalizeConfig, NODE_UPGRADE_PATH, HEALTH_PATH
24
24
  import { registerSetupRoutes } from './setup-routes.js'
25
25
 
26
26
  /** 角色取值。 */
27
- export const ROLE = Object.freeze({ HUB: 'hub', WORKER: 'worker', BOTH: 'both' })
27
+ export const ROLE = Object.freeze({ HUB: 'hub', WORKER: 'worker', BOTH: 'both', AUTO: 'auto' })
28
28
 
29
29
  /**
30
30
  * 解析本机角色。
@@ -66,6 +66,26 @@ export function resolveRoleDetail(config) {
66
66
  if (typeof raw === 'string') {
67
67
  const norm = raw.trim().toLowerCase()
68
68
  if (norm === ROLE.HUB || norm === ROLE.WORKER || norm === ROLE.BOTH) return { role: norm }
69
+ // ── auto:按配置推断(2026-10-17,"开箱即用"的落点)──────────────
70
+ //
71
+ // 判据只有一条,但它是**副机与主机最本质的区别**:
72
+ // · 配了 `hubUrl` = 这台机器要去连别人 = **副机**
73
+ // · 没配 hubUrl = 这台机器不需要连别人 = **主机**
74
+ //
75
+ // 为什么这条判据可靠:
76
+ // · hubUrl 是副机独有的字段,hub 侧根本不读它(hub 是监听方)
77
+ // · 用户配 hubUrl 的唯一目的就是"连主机"
78
+ // · 配对码展开后必然产出 hubUrl(见 lib/pairing.js),所以"贴了配对码"
79
+ // 就等于"有 hubUrl"
80
+ //
81
+ // 这样主机装完零操作(默认 hub),副机贴完配对码也零操作(自动 worker)。
82
+ // 比"两台都要点一下"更接近开箱即用,又不像"写死 hub"那样给副机开一个
83
+ // 不该开的无鉴权端点。
84
+ if (norm === 'auto' || norm === ROLE.AUTO) {
85
+ const hubUrl = typeof config.hubUrl === 'string' ? config.hubUrl.trim() : ''
86
+ if (hubUrl !== '') return { role: ROLE.WORKER, inferred: 'hubUrl' }
87
+ return { role: ROLE.HUB, inferred: 'no-hubUrl' }
88
+ }
69
89
  }
70
90
  // 无法识别:**不再回退 hub**(那会静默开一个无鉴权入口)。
71
91
  // 当成"未配置",由面板引导用户重新选。
@@ -206,13 +226,42 @@ export function apply(ctx, config = {}) {
206
226
 
207
227
  export const Config = z.object({
208
228
  role: z
209
- // ⚠ 空字符串是**合法值**,表示"还没配置" —— 见 resolveRoleDetail 的说明。
210
- // 不能用 `.default('hub')`:那会让副机装完以 hub 身份启动(注册一个
211
- // nodeToken 为空、不校验凭证的直连端点),且面板不会显示引导。
212
- .union([z.const('hub'), z.const('worker'), z.const('both'), z.const('')])
213
- .default('')
229
+ // ⚠ 默认 `'auto'`:**按配置推断角色**,而不是写死一个(2026-10-17)。
230
+ //
231
+ // ── 为什么不是写死 hub ──────────────────────────────────────────────
232
+ //
233
+ // v0.7.0 之前默认 hub,问题是:**副机装完会以 hub 身份启动** —— 注册一个
234
+ // nodeToken 为空、不校验凭证的直连端点。这在安全上是实打实的漏洞面。
235
+ //
236
+ // ── 为什么不是写死 ''(未配置)──────────────────────────────────────
237
+ //
238
+ // v0.7.0~0.8.1 默认 `''`,问题是:**两台机器都要操作一次**,而"开箱即用"
239
+ // 的目标是主机装完零操作。而且升级时包内默认容易把 profile 里没显式写的
240
+ // role 冲成空(实测踩过)。
241
+ //
242
+ // ── auto 的判据(见 resolveRoleDetail)─────────────────────────────
243
+ //
244
+ // 1. 用户显式写了 hub/worker/both → 用它(最高优先,永不猜错)
245
+ // 2. 配了 `hubUrl`(副机的特征) → worker
246
+ // 3. 都没配 → hub(默认主机)
247
+ //
248
+ // 这样:
249
+ // · 主机装完 → 没 hubUrl → 自动 hub → **零操作** ✅
250
+ // · 副机填完配对码 → 有 hubUrl → 自动 worker → **零操作** ✅
251
+ // · 副机还没填 → 暂时 hub,但面板立刻显示"填配对码成为副机"
252
+ //
253
+ // 空字符串仍是**合法值**,表示"用户主动清空了,需要引导"。
254
+ .union([
255
+ z.const('hub'),
256
+ z.const('worker'),
257
+ z.const('both'),
258
+ z.const('auto'),
259
+ z.const(''),
260
+ ])
261
+ .default('auto')
214
262
  .description(
215
- '本机角色:hub=主机(派活方);worker=副机(干活方);both=同一台机器自测;'
263
+ '本机角色:auto=自动推断(默认:配了 hubUrl 就是副机,否则是主机);'
264
+ + 'hub=主机(派活方);worker=副机(干活方);both=同一台机器自测;'
216
265
  + '留空=未配置(打开 Web 面板会显示引导,让你选)',
217
266
  ),
218
267
 
@@ -390,11 +390,29 @@ export function createFsService(cfg) {
390
390
  if (st.isDirectory()) {
391
391
  scan(dir)
392
392
  } else if (st.isFile()) {
393
- // 单文件 grep:直接匹配该文件
393
+ // 单文件 grep:直接匹配该文件。
394
+ //
395
+ // ⚠ 必须有大小护栏(2026-10-17 修)。目录扫描路径有 `GREP_FILE_MAX`
396
+ // (2MB)与 `GREP_TIME_MS`(10s)双护栏,但**这个分支原来什么都没查**:
397
+ // 直接 `readFileSync` 全文。触发形态是 `remote_grep` 的 path 指向工作区
398
+ // 内一个巨大文本文件(应用日志 / 数据库转储 / core dump):
399
+ //
400
+ // · 巨文件整体进内存 → 副机 OOM(副机是常驻进程,OOM 即全部任务中断)
401
+ // · `readFileSync` 同步阻塞事件循环 → 期间 ws 帧、心跳、其他任务的
402
+ // 输出全部停摆
403
+ //
404
+ // 而且这条路径是**可达的**:agent 会自主选择 grep 目标。属于 DoS 面。
405
+ //
406
+ // 现在与扫描路径同口径:超 `GREP_FILE_MAX` 就跳过并计入 filesSkipped
407
+ // (而不是静默读爆内存,也不是报错中断整个 grep —— 用户可能只是想
408
+ // 在目录里搜,其中恰好有个大文件)。
394
409
  let fd
395
410
  try {
396
411
  fd = fs.openSync(dir, 'r')
397
- if (!looksBinary(fd)) {
412
+ if (st.size > GREP_FILE_MAX) {
413
+ // 跳过超大文件,与目录扫描分支的判定一致(那边也是跳过不报错)
414
+ filesSkipped++
415
+ } else if (!looksBinary(fd)) {
398
416
  filesScanned++
399
417
  const lines = fs.readFileSync(dir).toString('utf8').split('\n')
400
418
  for (let i = 0; i < lines.length && matches.length < maxN; i++) {
package/lib/node/index.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  validateTaskStart,
36
36
  } from '../protocol.js'
37
37
  import { ArtifactService } from './artifact-service.js'
38
- import { defaultRoots } from './exec.js'
38
+ import { defaultRoots, resolveWorkspace } from './exec.js'
39
39
  // P2-11:hub 身份验证 —— 指纹归一化 + TOFU 钉住落盘(<stateDir>/hub-pin.json)。
40
40
  import { normalizeFingerprint } from '../identity.js'
41
41
  import { clearHubPin, loadHubPin, saveHubPin } from './hub-pin.js'
@@ -54,6 +54,15 @@ import { TaskStore } from './task-store.js'
54
54
  // 远程文件工具集(提案 #25):ls/read/grep/tree 只读实现,与任务链路共用
55
55
  // roots / denyPaths / caseInsensitive 三个配置(同一个安全边界,两处入口)。
56
56
  import { createFsService } from './fs-service.js'
57
+ // 工作区 SMB 共享(2026-10-17):只探测 + 生成命令,不执行(需管理员)。
58
+ import {
59
+ SHARE_ACCESS,
60
+ buildMountCommand,
61
+ buildShareCommand,
62
+ buildUnshareCommand,
63
+ isAdmin,
64
+ probeShareState,
65
+ } from './share-service.js'
57
66
  import { NodeTransport, TRANSPORT_MODE } from './transport.js'
58
67
 
59
68
  const PLUGIN_NAME = 'dsh-remote-agent'
@@ -958,6 +967,12 @@ function startWorker(ctx, config) {
958
967
  // reqId 就是请求信封 id(hub 按 replyTo 对 pending 表)。
959
968
  handleFsReq(frame, d)
960
969
  return
970
+ case HUB_TO_NODE.SHARE_REQ:
971
+ // 工作区 SMB 共享(2026-10-17):**只探测 + 生成命令**,不执行。
972
+ // 执行需要管理员,而 DSH 通常以普通用户跑 —— 由用户在提权 PowerShell
973
+ // 里手动做(面板给可复制命令)。见 lib/node/share-service.js 头注释。
974
+ handleShareReq(frame, d)
975
+ return
961
976
  case HUB_TO_NODE.ERROR:
962
977
  logger.warn('hub reported error', { code: d.code, message: d.message, replyTo: d.replyTo })
963
978
  return
@@ -991,6 +1006,76 @@ function startWorker(ctx, config) {
991
1006
  }))
992
1007
  }
993
1008
 
1009
+ /**
1010
+ * 处理工作区共享请求(2026-10-17)。
1011
+ *
1012
+ * 支持三个 op,**全部是只读探测或生成命令**,不执行任何需要管理员的动作:
1013
+ *
1014
+ * · `probe` —— 当前是否管理员 + 哪些 dsh-work 共享已存在
1015
+ * · `plan` —— 生成共享命令 + 主机侧 net use 命令(只读/读写)
1016
+ * · `unshare-plan` —— 生成卸载命令
1017
+ *
1018
+ * 为什么不执行:`New-SmbShare` 需要管理员,DSH 通常以普通用户跑。
1019
+ * Node 里提权失败的表现(静默失败 / 被安全软件拦 / 权限半生效)比让用户
1020
+ * 手动跑一条命令难排查得多。所以这里只"生成命令",执行交给用户。
1021
+ */
1022
+ function handleShareReq(frame, d) {
1023
+ const op = typeof d.op === 'string' ? d.op : ''
1024
+ const reply = (payload) => send(NODE_TO_HUB.SHARE_RES, { replyTo: frame.id, ...payload })
1025
+ try {
1026
+ if (op === 'probe') {
1027
+ // 探测要查的目录:默认用 roots(这是本机的安全边界,共享也只该共享它们)
1028
+ const dirs = Array.isArray(d.dirs) && d.dirs.length > 0
1029
+ ? d.dirs.filter((x) => typeof x === 'string' && x !== '')
1030
+ : roots
1031
+ reply({ ok: true, result: probeShareState({ dirs }) })
1032
+ return
1033
+ }
1034
+ if (op === 'plan') {
1035
+ const dir = typeof d.dir === 'string' ? d.dir.trim() : ''
1036
+ if (dir === '') {
1037
+ reply({ ok: false, code: ERROR_CODE.INTERNAL, message: 'plan 需要 dir(要共享的目录)' })
1038
+ return
1039
+ }
1040
+ // 共享目录必须在 roots 内 —— 与 fs 工具同一条安全边界。
1041
+ // 用户可以在副机共享任意目录,但**通过插件**只能共享已授权的工作区。
1042
+ const resolved = resolveWorkspace(dir, { roots, caseInsensitive, defaultDir: roots[0] })
1043
+ const access = d.access === SHARE_ACCESS.WRITE ? SHARE_ACCESS.WRITE : SHARE_ACCESS.READ
1044
+ const { shareName, command } = buildShareCommand({ dir: resolved, access })
1045
+ reply({
1046
+ ok: true,
1047
+ result: {
1048
+ dir: resolved,
1049
+ access,
1050
+ shareName,
1051
+ shareCommand: command,
1052
+ // 主机侧映射命令:面板直接给用户复制
1053
+ mountCommand: buildMountCommand({ host: d.host || '', shareName, drive: d.drive || 'X:' }),
1054
+ admin: isAdmin(),
1055
+ },
1056
+ })
1057
+ return
1058
+ }
1059
+ if (op === 'unshare-plan') {
1060
+ const shareName = typeof d.shareName === 'string' ? d.shareName.trim() : ''
1061
+ if (shareName === '') {
1062
+ reply({ ok: false, code: ERROR_CODE.INTERNAL, message: 'unshare-plan 需要 shareName' })
1063
+ return
1064
+ }
1065
+ reply({
1066
+ ok: true,
1067
+ result: { shareName, unshareCommand: buildUnshareCommand(shareName), admin: isAdmin() },
1068
+ })
1069
+ return
1070
+ }
1071
+ reply({ ok: false, code: ERROR_CODE.INTERNAL, message: `unknown share op: ${op || '(empty)'}` })
1072
+ } catch (err) {
1073
+ const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
1074
+ logger.warn('share op failed', { op, code, error: msgOf(err) })
1075
+ reply({ ok: false, code, message: msgOf(err) })
1076
+ }
1077
+ }
1078
+
994
1079
  /* 9) 事件接线 ------------------------------------------------------- */
995
1080
  const listenerDisposers = []
996
1081
  const on = (evt, fn) => {
@@ -641,6 +641,21 @@ export class InProcessRunner {
641
641
  )
642
642
 
643
643
  dlog('before createAgent', { sessionIdFor, cwd, agentOptions })
644
+
645
+ // ⚠ **先占位再 await**(2026-10-17 修 L3)。
646
+ //
647
+ // 原实现把 `this.active.set(taskId, { handle, controller })` 放在
648
+ // `await createAgent()` **之后**。于是 createAgent 在途的那几毫秒里,
649
+ // hub 发 `task/cancel` → `cancel(taskId)` 查不到记录 → 返回 false →
650
+ // hub 认为"取消失败",而任务最终会跑完并按 completed 落账 ——
651
+ // **正是 #26-5 修过的形态在窗口期复现**。
652
+ //
653
+ // 窗口窄(createAgent 毫秒级)但取消语义的承诺是"请求即达"。所以先占位
654
+ // (handle 置 null),createAgent 完成后再补上。`cancel()` 里对
655
+ // `rec.handle` 的空值已有保护(`rec.handle.agent.cancel` 包在 try 里,
656
+ // 而 `controller.abort()` 一定生效 —— 后者正是 #26-5 的修复点)。
657
+ this.active.set(taskId, { handle: null, controller })
658
+
644
659
  handle = await agentLoop.createAgent(this.ctx, {
645
660
  sessionId: sessionIdFor,
646
661
  meta: { cwd },
@@ -656,6 +671,9 @@ export class InProcessRunner {
656
671
  handleKeys: (() => { try { return Object.keys(handle ?? {}).slice(0, 10) } catch { return null } })(),
657
672
  })
658
673
 
674
+ // 补齐占位记录里的 handle(cancel 可能已经在 createAgent 期间触发过,
675
+ // 此时 controller 已被 abort —— 后续 followup 会因 signal 已中止而立刻
676
+ // 走 abortPromise 分支,符合预期)
659
677
  this.active.set(taskId, { handle, controller })
660
678
 
661
679
  // 观察事件:给 hub 送进度,并在会话日志之外保留一份实时文本(防日志投影差异)
@@ -829,9 +847,13 @@ export class InProcessRunner {
829
847
  const rec = this.active.get(taskId)
830
848
  if (!rec) return false
831
849
  try {
850
+ // handle 可能还是 null(L3 修复后先占位再 await createAgent)——
851
+ // 此时 agent 还没建出来,没有 agent.cancel 可调。抛错被吞掉即可,
852
+ // 真正的取消靠下面的 controller.abort():createAgent 拿到的 signal
853
+ // 已经中止,后续 followup 会立刻走 abortPromise 分支。
832
854
  rec.handle.agent.cancel({ kind: 'user' })
833
855
  } catch {
834
- /* agent 可能已停 */
856
+ /* agent 可能已停,或还没建出来(占位阶段) */
835
857
  }
836
858
  try {
837
859
  rec.controller.abort()