pisper 0.6.14 → 0.6.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.
Files changed (197) hide show
  1. package/bin/pisper.mjs +5 -1
  2. package/lib/executor-node.mjs +590 -0
  3. package/lib/npm-update.mjs +2 -0
  4. package/package.json +5 -5
  5. package/web/.vite/manifest.json +852 -856
  6. package/web/assets/{AppDialog-DrhCZm1r.js → AppDialog-CgJEhcYw.js} +1 -1
  7. package/web/assets/{AppOverlays-CxgReqGn.js → AppOverlays-BBlYyb_U.js} +1 -1
  8. package/web/assets/{AppSelect-BGVBQRP1.js → AppSelect-BD6RAaJ5.js} +1 -1
  9. package/web/assets/{AssetsPage-CKK-byQY.js → AssetsPage-D3F4T0_j.js} +1 -1
  10. package/web/assets/BoardPage-CO7nU2Mw.js +3 -0
  11. package/web/assets/BotAvatar-CNL-APwP.js +1 -0
  12. package/web/assets/ChannelsPage-CsRbj9St.js +1 -0
  13. package/web/assets/{ChatAppearancePage-BhqRLf-2.js → ChatAppearancePage-Sc1V75I3.js} +1 -1
  14. package/web/assets/{ChatHistoryPage-DBsNyGYj.js → ChatHistoryPage-BT0mIaDN.js} +1 -1
  15. package/web/assets/ChatPage-C-hswZf9.js +553 -0
  16. package/web/assets/ConfigPage-DH2d8YGn.js +5 -0
  17. package/web/assets/{DesktopWindowControls-DCtIkpTs.js → DesktopWindowControls-Bo5vvIFH.js} +1 -1
  18. package/web/assets/{FeaturePage-Bkt1wWVg.js → FeaturePage-B3wCvGpC.js} +1 -1
  19. package/web/assets/{FileAttachmentPreview-Cnmi_2U-.js → FileAttachmentPreview-D8YqjbeC.js} +1 -1
  20. package/web/assets/{FloatingWidgets-BIJSFz34.js → FloatingWidgets-Dz_T7XWC.js} +1 -1
  21. package/web/assets/FocusRuntimeControls-hYDoXwg7.js +1 -0
  22. package/web/assets/{GitDiffViewer-rjt5_KOt.js → GitDiffViewer-CBju_Sj4.js} +3 -3
  23. package/web/assets/ManageProjectsDialog-Dtb2K7BK.js +1 -0
  24. package/web/assets/{MarkdownMessage-yl-PSRAz.js → MarkdownMessage-BDztlYIy.js} +1 -1
  25. package/web/assets/{McpPage-D6GyZCpt.js → McpPage-DQ6AILOH.js} +1 -1
  26. package/web/assets/MemoryPage-CK4evs_P.js +1 -0
  27. package/web/assets/{MobileNavigation-DcBH2I9Y.js → MobileNavigation-BICCops3.js} +1 -1
  28. package/web/assets/MobileViewportStabilizer-uKI4Zp7f.js +1 -0
  29. package/web/assets/{MorePage-BBfiQbiP.js → MorePage-DuML7bhr.js} +1 -1
  30. package/web/assets/NewBotDialog--2DSe9Sy.js +1 -0
  31. package/web/assets/{NewFolderDialog-BinpbTs8.js → NewFolderDialog-S-Qzjzfu.js} +1 -1
  32. package/web/assets/{PageHeader-D_J-19ni.js → PageHeader-B78dHJed.js} +1 -1
  33. package/web/assets/{PhoneDrawer-CwxDkWZQ.js → PhoneDrawer-DHRBi4XA.js} +1 -1
  34. package/web/assets/PhoneMenuButton-BjDV-dPl.js +1 -0
  35. package/web/assets/PhoneTopBar-BuyzXL4Z.js +1 -0
  36. package/web/assets/PluginsPage-Cd9bu2AS.js +1 -0
  37. package/web/assets/{SafeModePrompt-CLyt-z14.js → SafeModePrompt-BnoccbWI.js} +1 -1
  38. package/web/assets/SchedulesPage-DUhUUQP5.js +1 -0
  39. package/web/assets/SessionBrowserPane-B3weAiaB.js +2 -0
  40. package/web/assets/SessionContextPanel-BtxUlk9m.js +2 -0
  41. package/web/assets/SessionHome-ByiFmH18.js +1 -0
  42. package/web/assets/SessionInspector-VKOz4Rxs.js +1 -0
  43. package/web/assets/SessionShell-BsDAwWHd.js +1 -0
  44. package/web/assets/{SettingsSidebar-IqsOv5h1.js → SettingsSidebar-B-COw6Uw.js} +1 -1
  45. package/web/assets/{ShortcutHelpDialog-DKDAF94g.js → ShortcutHelpDialog-DquhK-Uh.js} +1 -1
  46. package/web/assets/{ShortcutKeys-DQFnMnX1.js → ShortcutKeys-BgwCWvNY.js} +1 -1
  47. package/web/assets/{SidebarUpdateStatus-Cbwy5ii9.js → SidebarUpdateStatus-Ci0ugb91.js} +1 -1
  48. package/web/assets/{SkillsPage-DTOzYSaS.js → SkillsPage-B46uoO_g.js} +1 -1
  49. package/web/assets/{TaskProcessesPane-DRFmGkE3.js → TaskProcessesPane-CI_5lWbf.js} +1 -1
  50. package/web/assets/{TaskTerminalView-DWNACXyI.js → TaskTerminalView-C2acHnAH.js} +1 -1
  51. package/web/assets/{TerminalPanel-QEt-SRiM.js → TerminalPanel-BAQ2VwKg.js} +1 -1
  52. package/web/assets/{WebDesktopPet-4CDch3gC.js → WebDesktopPet-D3bdxKPz.js} +1 -1
  53. package/web/assets/{WorkflowsPage-AMKQBlPo.js → WorkflowsPage-Dp1PRKQb.js} +1 -1
  54. package/web/assets/WorkspacePicker-BJhBGRH0.js +1 -0
  55. package/web/assets/accordion-CiJMkjqX.js +1 -0
  56. package/web/assets/{api-BO7LAPSQ.js → api-CKJOip3Q.js} +1 -1
  57. package/web/assets/{app-update-DKWPHTUY.js → app-update-yPQVXuw5.js} +1 -1
  58. package/web/assets/{approval-confirm-ByiI22Wo.js → approval-confirm-Cv754IjO.js} +1 -1
  59. package/web/assets/board-messages-CIYKjAla.js +1 -0
  60. package/web/assets/board-projects-wd79Rqt5.js +2 -0
  61. package/web/assets/channels-messages-KabR1HOR.js +1 -0
  62. package/web/assets/chat-api-Bvirerdj.js +1 -0
  63. package/web/assets/{client-store-6GNC8uyK.js → client-store-BaTctJrB.js} +1 -1
  64. package/web/assets/collapsible-Dcoy-qnz.js +1 -0
  65. package/web/assets/config-search-KYVClDpx.js +1 -0
  66. package/web/assets/{dialog-CUhFgsVd.js → dialog-Cb_sjjQV.js} +1 -1
  67. package/web/assets/{collapsible-DMaUQRX4.js → dist-DUSSw9eQ.js} +1 -1
  68. package/web/assets/events-CZTn9wa-.js +1 -0
  69. package/web/assets/file-diff-GxOMJkLj.js +1 -0
  70. package/web/assets/folder-paths--euvEC5U.js +1 -0
  71. package/web/assets/{hosts-1yQREdMi.js → hosts-BRPznOHU.js} +1 -1
  72. package/web/assets/i18n-DEs6BBob.js +1 -0
  73. package/web/assets/{index-CsE52ydO.js → index-C5aKcTGw.js} +2 -2
  74. package/web/assets/index-DD2H2OOC.css +2 -0
  75. package/web/assets/message-circle-question-mark-D7ehy_mi.js +1 -0
  76. package/web/assets/{mobile-remote-fallback-NJCTBu85.js → mobile-remote-fallback-DG_urwIg.js} +1 -1
  77. package/web/assets/mount-app-BdXRP9D-.js +9 -0
  78. package/web/assets/navigation-DkcKi1_5.js +1 -0
  79. package/web/assets/new-task-uLEgjs2Z.js +1 -0
  80. package/web/assets/{palette-navigation-ByC1_QS5.js → palette-navigation-HbVGltCo.js} +1 -1
  81. package/web/assets/{permission-rules-api-BoJ_1W-m.js → permission-rules-api-C9oRUm6Z.js} +1 -1
  82. package/web/assets/{pick-system-directory-B_RJmP26.js → pick-system-directory-B__qKy-X.js} +1 -1
  83. package/web/assets/placements-Bn0vPyEI.js +2 -0
  84. package/web/assets/{popover--UmYQ9yB.js → popover-CiVxYUEU.js} +1 -1
  85. package/web/assets/public-9meoHs0K.js +4 -0
  86. package/web/assets/public-D6X148yH.js +2 -0
  87. package/web/assets/public-DenVz3gW.js +2 -0
  88. package/web/assets/{public-components-DYGekkA7.js → public-components-BfHcj0Pn.js} +1 -1
  89. package/web/assets/public-diff-BqcNo4lt.js +1 -0
  90. package/web/assets/public-events-DCyxz5zX.js +1 -0
  91. package/web/assets/public-flags-BOI2nkm4.js +1 -0
  92. package/web/assets/public-launch-D0t4Hqf5.js +1 -0
  93. package/web/assets/public-sidebar-CdxAohEH.js +1 -0
  94. package/web/assets/react-bits-BsPlLi8F.css +1 -0
  95. package/web/assets/routes-DIeaqWVc.js +1 -0
  96. package/web/assets/runtime-capabilities-CHYOF_hE.js +1 -0
  97. package/web/assets/{runtime-capabilities-store-C2Qv7uDP.js → runtime-capabilities-store-D2d_9z0D.js} +1 -1
  98. package/web/assets/{safe-mode-instance-BiAPYJ2-.js → safe-mode-instance-hnF1BJYr.js} +1 -1
  99. package/web/assets/safe-mode-sentinel-CPmhLLXT.js +1 -0
  100. package/web/assets/{session-change-summary-api-DgRT32uh.js → session-change-summary-api-DmmNGbP7.js} +1 -1
  101. package/web/assets/{session-home-BktP6sGF.js → session-home-BAFT2Fk1.js} +1 -1
  102. package/web/assets/{session-organization-api-DFu3Chto.js → session-organization-api-Bs8DdxU1.js} +1 -1
  103. package/web/assets/{session-scope-DQPNgQMm.js → session-scope-XRVRtwow.js} +1 -1
  104. package/web/assets/{session-tree-actions-DfO8QHBO.js → session-tree-actions-Dzj7hrvM.js} +1 -1
  105. package/web/assets/{session-tree-summary-BwYRdui2.js → session-tree-summary-BXVKYODc.js} +1 -1
  106. package/web/assets/settings-navigation-DiP_AM1C.js +1 -0
  107. package/web/assets/{shortcuts-Cvp9WD9O.js → shortcuts-IA6Uq_O6.js} +1 -1
  108. package/web/assets/side-chat-context-CZ4IqNxt.js +7 -0
  109. package/web/assets/sidebar-Do_8-Hyh.js +2 -0
  110. package/web/assets/sidebar-store-DuRlD5qg.js +1 -0
  111. package/web/assets/{speech-session-DK_09Wky.js → speech-session-D_W5Ulli.js} +1 -1
  112. package/web/assets/{startup-queries-6Oztqpto.js → startup-queries-DeNu46nT.js} +1 -1
  113. package/web/assets/storage-DH3bQdy6.js +1 -0
  114. package/web/assets/tool-labels-ghvpPFmc.js +1 -0
  115. package/web/assets/{use-i18n-B5p-Kzvz.js → use-i18n-CUS2_EGy.js} +1 -1
  116. package/web/assets/use-mobile-C2tPBAza.js +1 -0
  117. package/web/assets/use-studio-actions-CYnwfaOn.js +3 -0
  118. package/web/assets/useNewProjectPicker-CacWA0IS.js +1 -0
  119. package/web/assets/usePhoneShell-cYrS53S8.js +1 -0
  120. package/web/assets/useSessionRowActions-DtunSg_Q.js +1 -0
  121. package/web/assets/useSidebarModel-6sqN4VA5.js +1 -0
  122. package/web/assets/web-preview-Dgng80ZD.js +1 -0
  123. package/web/assets/{web-preview-events-DQchQHt3.js → web-preview-events-JGegs4uF.js} +1 -1
  124. package/web/assets/workflow-messages-Cj35aJZc.js +1 -0
  125. package/web/assets/workspace-order-store-BuPmheRT.js +1 -0
  126. package/web/index.html +2 -2
  127. package/web/assets/BoardPage-vwv5vTDm.js +0 -3
  128. package/web/assets/ChannelsPage-L-wcVDSN.js +0 -1
  129. package/web/assets/ChatPage-DS0euFxR.js +0 -553
  130. package/web/assets/ConfigPage-pOzONr8e.js +0 -5
  131. package/web/assets/DispatchOverlay-n-qbkOP-.js +0 -1
  132. package/web/assets/FocusRuntimeControls-C7NIdpV4.js +0 -1
  133. package/web/assets/LaunchConsole-DtxzpXJo.js +0 -3
  134. package/web/assets/ManageProjectsDialog-RiFg3h1p.js +0 -1
  135. package/web/assets/MemoryPage-Dsx_6JpI.js +0 -1
  136. package/web/assets/MobileViewportStabilizer-OtwVmIPm.js +0 -1
  137. package/web/assets/PhoneMenuButton-B0AwvqEo.js +0 -1
  138. package/web/assets/PhoneTopBar-Dw0mIaWC.js +0 -1
  139. package/web/assets/PluginsPage-BzxPAX26.js +0 -1
  140. package/web/assets/QuickPromptIdeas-Cd3SwBqS.js +0 -1
  141. package/web/assets/SchedulesPage-Cbgl5v_8.js +0 -1
  142. package/web/assets/SessionBrowserPane-CB7jn-MS.js +0 -2
  143. package/web/assets/SessionContextPanel-CNmAp0ed.js +0 -2
  144. package/web/assets/SessionHome-Ck_daJT0.js +0 -1
  145. package/web/assets/SessionInspector-cSzwsc4b.js +0 -1
  146. package/web/assets/SessionShell-BOZ7h0yz.js +0 -1
  147. package/web/assets/WorkspacePicker-B9vYtfab.js +0 -1
  148. package/web/assets/board-launch-BmmPYZe8.js +0 -1
  149. package/web/assets/board-messages-BOWWyhz_.js +0 -1
  150. package/web/assets/board-projects-BU9fZB1q.js +0 -1
  151. package/web/assets/channels-messages-DazltGVm.js +0 -1
  152. package/web/assets/chat-api-DkdyVQY9.js +0 -1
  153. package/web/assets/config-search-HBKDuym0.js +0 -1
  154. package/web/assets/events-DrAPv0se.js +0 -1
  155. package/web/assets/file-text-N9JICX6E.js +0 -1
  156. package/web/assets/folder-paths-DQTpuGh9.js +0 -1
  157. package/web/assets/i18n-CHmr7GzM.js +0 -1
  158. package/web/assets/index-CfTBR7Rs.css +0 -2
  159. package/web/assets/list-checks-B5Jl4aiO.js +0 -1
  160. package/web/assets/mount-app-DfSFF0xv.js +0 -9
  161. package/web/assets/navigation-EoDz3gT8.js +0 -1
  162. package/web/assets/new-task-C3wuHTor.js +0 -1
  163. package/web/assets/placements-CqwqXPTp.js +0 -2
  164. package/web/assets/public-diff-DK-VNIar.js +0 -1
  165. package/web/assets/public-fqyTjQ0F.js +0 -2
  166. package/web/assets/public-jQjEc6OO.js +0 -1
  167. package/web/assets/public-launch-CT_m9KWo.js +0 -1
  168. package/web/assets/react-bits-DQli9s7O.css +0 -1
  169. package/web/assets/routes-Da0OHSy6.js +0 -1
  170. package/web/assets/runtime-capabilities-4_ZwLBqF.js +0 -1
  171. package/web/assets/safe-mode-sentinel-Ccu3Vmta.js +0 -1
  172. package/web/assets/session-workspaces-BWTBYaGT.js +0 -1
  173. package/web/assets/settings-navigation-_yUm71Uk.js +0 -1
  174. package/web/assets/shield-B1ruzHd0.js +0 -1
  175. package/web/assets/side-chat-context-BfOm4dFp.js +0 -7
  176. package/web/assets/sidebar-BLeRyb55.js +0 -2
  177. package/web/assets/sidebar-commands-ZA1UOA_F.js +0 -1
  178. package/web/assets/sidebar-store-1-aNPlDu.js +0 -1
  179. package/web/assets/storage-BY6wEHPw.js +0 -1
  180. package/web/assets/tool-labels-BUEvdohv.js +0 -1
  181. package/web/assets/use-mobile-DauOEda6.js +0 -1
  182. package/web/assets/use-studio-actions-D6c_pSJK.js +0 -3
  183. package/web/assets/useBoardItems-CzEbgTLJ.js +0 -1
  184. package/web/assets/useFleetSystemInfo-Bd8zJIFa.js +0 -1
  185. package/web/assets/useNewProjectPicker-DQY1X_ua.js +0 -1
  186. package/web/assets/useNow-Bxn4_OW9.js +0 -1
  187. package/web/assets/usePagePrimaryAction-CWPC0C_f.js +0 -1
  188. package/web/assets/usePhoneShell-Bvb7m-m4.js +0 -1
  189. package/web/assets/useSessionListQuery-D44XZfGr.js +0 -2
  190. package/web/assets/useSessionRowActions-Cr6SUAeE.js +0 -1
  191. package/web/assets/useSidebarModel-CWED0rsw.js +0 -1
  192. package/web/assets/wand-sparkles-CvEBNUFz.js +0 -1
  193. package/web/assets/web-preview-CqwSe9Zn.js +0 -1
  194. package/web/assets/workflow-messages-CMCQrs5q.js +0 -1
  195. package/web/assets/workspace-order-store-RYWvGOeR.js +0 -1
  196. /package/web/assets/{AnimatedList-D8fxDNGr.js → AnimatedList-DB5vqafO.js} +0 -0
  197. /package/web/assets/{ClickSpark-DmnEAJNI.js → ClickSpark-B1Rn7ZwL.js} +0 -0
package/bin/pisper.mjs CHANGED
@@ -10,7 +10,11 @@ const frontendRoot = fileURLToPath(new URL('../web', import.meta.url))
10
10
 
11
11
  try {
12
12
  const arguments_ = process.argv.slice(2)
13
- if (!handleNpmHelp(arguments_) && !(await handleNpmUpdate(arguments_))) {
13
+ // 执行节点只用 Node 内置模块,不需要下载应用本体。
14
+ if (arguments_[0] === 'executor') {
15
+ const { executorCommand } = await import('../lib/executor-node.mjs')
16
+ process.exitCode = await executorCommand(arguments_.slice(1))
17
+ } else if (!handleNpmHelp(arguments_) && !(await handleNpmUpdate(arguments_))) {
14
18
  const installation = await ensurePisperInstallation()
15
19
  const child = spawn(installation.executable, arguments_, {
16
20
  env: {
@@ -0,0 +1,590 @@
1
+ // Pisper 执行节点(B6):让另一台电脑上的 Pisper(“大脑”)在这台电脑上替 Bot 读写代码、跑命令。
2
+ // pisper executor pair --brain https://<地址>:<端口> --fingerprint SHA256:… --code ABCD-EF23 --root <目录> [--root …] [--name 名字] [--no-shell]
3
+ // pisper executor # 连上大脑,开始干活(Ctrl+C 停止)
4
+ // 设计要点(runtime/services/bots/README.md「执行环境接口」):
5
+ // - 节点主动连回大脑(HTTPS 长轮询),不开放端口;只认配对时确认过的证书指纹。
6
+ // - 节点不保存会话、不持有模型密钥;只保存配对信息(0600)与 Bot 任务的 worktree。
7
+ // - 文件操作只在 --root 允许的目录与自己的 worktree 目录里,按真实路径检查(符号链接绕不出去);
8
+ // git 只放行 worktree 管理用到的子命令;--no-shell 时不执行任意命令。
9
+ // 只用 Node 内置模块:npm 包里的这个文件不依赖下载的应用本体。
10
+ import { execFile, spawn } from 'node:child_process'
11
+ import { X509Certificate } from 'node:crypto'
12
+ import { existsSync } from 'node:fs'
13
+ import {
14
+ access,
15
+ mkdir,
16
+ readdir,
17
+ readFile,
18
+ realpath,
19
+ stat,
20
+ writeFile,
21
+ chmod,
22
+ } from 'node:fs/promises'
23
+ import https from 'node:https'
24
+ import { homedir, hostname } from 'node:os'
25
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
26
+ import process from 'node:process'
27
+ import tls from 'node:tls'
28
+
29
+ const MAX_READ_BYTES = 16 * 1024 * 1024
30
+ const MAX_EXEC_OUTPUT = 8 * 1024 * 1024
31
+ const MAX_GLOB_VISITS = 200_000
32
+ const POLL_TIMEOUT_MS = 40_000
33
+ const CHUNK_FLUSH_MS = 150
34
+ // worktree 管理用到的 git 子命令;config 只允许读提交身份。
35
+ const GIT_SUBCOMMANDS = new Set([
36
+ 'rev-parse',
37
+ 'worktree',
38
+ 'check-ref-format',
39
+ 'add',
40
+ 'diff',
41
+ 'commit',
42
+ 'status',
43
+ 'cherry-pick',
44
+ 'branch',
45
+ 'config',
46
+ ])
47
+
48
+ export class NodeOperationError extends Error {
49
+ constructor(code, message) {
50
+ super(message)
51
+ this.code = code
52
+ }
53
+ }
54
+
55
+ function fail(code, message) {
56
+ throw new NodeOperationError(code, message)
57
+ }
58
+
59
+ export function executorHome(env = process.env) {
60
+ return env.PISPER_EXECUTOR_HOME || join(homedir(), '.pisper-executor')
61
+ }
62
+
63
+ // glob → 正则:支持 **、*、?、{a,b} 与 [...];路径分隔符统一成 /。
64
+ export function globToRegExp(pattern) {
65
+ let source = ''
66
+ const text = String(pattern).replaceAll('\\', '/')
67
+ for (let index = 0; index < text.length; index += 1) {
68
+ const char = text[index]
69
+ if (char === '*') {
70
+ if (text[index + 1] === '*') {
71
+ const slash = text[index + 2] === '/'
72
+ source += slash ? '(?:.*/)?' : '.*'
73
+ index += slash ? 2 : 1
74
+ } else source += '[^/]*'
75
+ } else if (char === '?') source += '[^/]'
76
+ else if (char === '{') {
77
+ const end = text.indexOf('}', index)
78
+ if (end < 0) source += '\\{'
79
+ else {
80
+ source += `(?:${text
81
+ .slice(index + 1, end)
82
+ .split(',')
83
+ .map((part) => part.replace(/[.+^$()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*'))
84
+ .join('|')})`
85
+ index = end
86
+ }
87
+ } else if (char === '[') {
88
+ const end = text.indexOf(']', index)
89
+ if (end < 0) source += '\\['
90
+ else {
91
+ source += `[${text.slice(index + 1, end).replace(/^!/, '^')}]`
92
+ index = end
93
+ }
94
+ } else source += char.replace(/[.+^$()|{}\\]/g, '\\$&')
95
+ }
96
+ return new RegExp(`^${source}$`)
97
+ }
98
+
99
+ export class ExecutorNode {
100
+ constructor({ roots, worktreeRoot, shell = true }) {
101
+ this.roots = roots.map((root) => resolve(root))
102
+ this.worktreeRoot = resolve(worktreeRoot)
103
+ this.shell = shell
104
+ this.running = new Map()
105
+ this.realRoots = null
106
+ }
107
+
108
+ report() {
109
+ return {
110
+ roots: this.roots,
111
+ worktreeRoot: this.worktreeRoot,
112
+ capabilities: { shell: this.shell, git: true },
113
+ platform: process.platform,
114
+ }
115
+ }
116
+
117
+ async #allowedRoots() {
118
+ if (this.realRoots) return this.realRoots
119
+ await mkdir(this.worktreeRoot, { recursive: true })
120
+ const roots = []
121
+ for (const root of [...this.roots, this.worktreeRoot]) {
122
+ try {
123
+ roots.push(await realpath(root))
124
+ } catch {}
125
+ }
126
+ this.realRoots = roots
127
+ return roots
128
+ }
129
+
130
+ // 路径必须是绝对路径,且按真实路径(最近一个已存在的上级解析符号链接)落在允许目录里。
131
+ async allowed(path) {
132
+ const text = String(path ?? '')
133
+ if (!text || text.includes('\0') || !isAbsolute(text))
134
+ fail('path_invalid', '路径必须是绝对路径。')
135
+ let existing = resolve(text)
136
+ const rest = []
137
+ for (;;) {
138
+ try {
139
+ existing = await realpath(existing)
140
+ break
141
+ } catch {
142
+ const parent = dirname(existing)
143
+ if (parent === existing) fail('path_not_allowed', `不在允许的目录里:${text}`)
144
+ rest.unshift(existing.slice(parent.length).replace(/^[\\/]+/, ''))
145
+ existing = parent
146
+ }
147
+ }
148
+ const real = rest.length ? join(existing, ...rest) : existing
149
+ const roots = await this.#allowedRoots()
150
+ if (!roots.some((root) => real === root || real.startsWith(`${root}${sep}`)))
151
+ fail('path_not_allowed', `不在允许的目录里:${text}`)
152
+ return real
153
+ }
154
+
155
+ async #git(args, cwd, timeoutMs) {
156
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string' || arg.includes('\0')))
157
+ fail('command_invalid', '命令参数无效。')
158
+ let index = 0
159
+ // 只接受提交身份的 -c;其余全局选项(-C、--git-dir、-c core.* 等)一律拒绝。
160
+ while (args[index] === '-c') {
161
+ if (!/^user\.(name|email)=/.test(args[index + 1] || ''))
162
+ fail('command_not_allowed', '只允许设置提交身份。')
163
+ index += 2
164
+ }
165
+ const subcommand = args[index]
166
+ if (!GIT_SUBCOMMANDS.has(subcommand))
167
+ fail('command_not_allowed', `不允许 git ${subcommand || ''}`.trim())
168
+ if (subcommand === 'config' && !(args.length === index + 2 && args[index + 1] === 'user.email'))
169
+ fail('command_not_allowed', '只允许读取提交身份。')
170
+ // 参数里的绝对路径(如 worktree 目录)同样要在允许目录里;相对路径不许用 .. 往上走
171
+ // (git 的引用名本来就不能含 ..,正常参数不受影响)。
172
+ for (const arg of args.slice(index + 1)) {
173
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(arg)) fail('path_not_allowed', '参数里不能有 ..。')
174
+ if (isAbsolute(arg) && !arg.startsWith('-')) await this.allowed(arg)
175
+ }
176
+ const directory = await this.allowed(cwd)
177
+ return new Promise((resolveResult, reject) => {
178
+ execFile(
179
+ 'git',
180
+ args,
181
+ {
182
+ cwd: directory,
183
+ timeout: Math.min(Math.max(1000, Number(timeoutMs) || 60_000), 10 * 60_000),
184
+ maxBuffer: MAX_EXEC_OUTPUT,
185
+ windowsHide: true,
186
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_EDITOR: 'true' },
187
+ },
188
+ (error, stdout, stderr) => {
189
+ if (error && typeof error.code !== 'number') {
190
+ reject(new NodeOperationError('command_failed', `git 执行失败:${error.message}`))
191
+ return
192
+ }
193
+ resolveResult({
194
+ code: error ? error.code : 0,
195
+ stdout: String(stdout),
196
+ stderr: String(stderr),
197
+ })
198
+ },
199
+ )
200
+ })
201
+ }
202
+
203
+ async #shell(id, { command, cwd, timeoutMs }, emit) {
204
+ if (!this.shell) fail('shell_disabled', '这台电脑没有允许执行命令(--no-shell)。')
205
+ const directory = await this.allowed(cwd)
206
+ const windows = process.platform === 'win32'
207
+ const shellPath = windows
208
+ ? process.env.ComSpec || 'cmd.exe'
209
+ : existsSync('/bin/bash')
210
+ ? '/bin/bash'
211
+ : '/bin/sh'
212
+ const child = spawn(shellPath, windows ? ['/d', '/s', '/c', command] : ['-c', command], {
213
+ cwd: directory,
214
+ detached: !windows,
215
+ windowsHide: true,
216
+ stdio: ['ignore', 'pipe', 'pipe'],
217
+ })
218
+ const kill = () => {
219
+ try {
220
+ if (!windows && child.pid) process.kill(-child.pid, 'SIGKILL')
221
+ else child.kill('SIGKILL')
222
+ } catch {}
223
+ }
224
+ this.running.set(id, kill)
225
+ const timer = setTimeout(
226
+ kill,
227
+ Math.min(Math.max(1000, Number(timeoutMs) || 600_000), 3_600_000),
228
+ )
229
+ child.stdout.on('data', (data) => emit(data))
230
+ child.stderr.on('data', (data) => emit(data))
231
+ return new Promise((resolveResult, reject) => {
232
+ child.once('error', (error) => {
233
+ clearTimeout(timer)
234
+ this.running.delete(id)
235
+ reject(new NodeOperationError('command_failed', error.message))
236
+ })
237
+ child.once('close', (code, signal) => {
238
+ clearTimeout(timer)
239
+ this.running.delete(id)
240
+ resolveResult({
241
+ exitCode: typeof code === 'number' ? code : signal ? 128 + 9 : null,
242
+ })
243
+ })
244
+ })
245
+ }
246
+
247
+ async #glob({ pattern, cwd, ignore, limit }) {
248
+ const base = await this.allowed(cwd)
249
+ const matcher = globToRegExp(pattern)
250
+ const ignored = (Array.isArray(ignore) ? ignore : []).map(globToRegExp)
251
+ const max = Math.min(Math.max(1, Number(limit) || 1000), 10_000)
252
+ const found = []
253
+ let visits = 0
254
+ const walk = async (directory) => {
255
+ if (found.length >= max || visits >= MAX_GLOB_VISITS) return
256
+ let entries
257
+ try {
258
+ entries = await readdir(directory, { withFileTypes: true })
259
+ } catch {
260
+ return
261
+ }
262
+ for (const entry of entries) {
263
+ visits += 1
264
+ if (found.length >= max || visits >= MAX_GLOB_VISITS) return
265
+ const path = join(directory, entry.name)
266
+ const rel = relative(base, path).replaceAll('\\', '/')
267
+ const probe = entry.isDirectory() ? `${rel}/` : rel
268
+ if (ignored.some((rule) => rule.test(rel) || rule.test(probe))) continue
269
+ if (entry.name === '.git') continue
270
+ if (matcher.test(rel)) found.push(rel)
271
+ if (entry.isDirectory() && !entry.isSymbolicLink()) await walk(path)
272
+ }
273
+ }
274
+ await walk(base)
275
+ return found
276
+ }
277
+
278
+ // 执行一个请求;emit 用来流式交回命令输出。返回 { ok, value } 或 { ok: false, error }。
279
+ async handle(request, emit = () => {}) {
280
+ const { id, op } = request
281
+ const args = request.args && typeof request.args === 'object' ? request.args : {}
282
+ try {
283
+ switch (op) {
284
+ case 'cancel':
285
+ this.running.get(String(args.id))?.()
286
+ return { ok: true, value: null }
287
+ case 'exec':
288
+ if (args.file !== 'git') fail('command_not_allowed', `不允许执行 ${args.file}`)
289
+ return { ok: true, value: await this.#git(args.args, args.cwd, args.timeoutMs) }
290
+ case 'shell':
291
+ return { ok: true, value: await this.#shell(id, args, emit) }
292
+ case 'fs.read': {
293
+ const path = await this.allowed(args.path)
294
+ const info = await stat(path)
295
+ if (info.size > MAX_READ_BYTES) fail('file_too_large', '文件太大,超过 16 MB。')
296
+ return { ok: true, value: (await readFile(path)).toString('base64') }
297
+ }
298
+ case 'fs.access': {
299
+ const path = await this.allowed(args.path)
300
+ await access(path, args.mode === 'rw' ? 6 : 4)
301
+ return { ok: true, value: null }
302
+ }
303
+ case 'fs.write': {
304
+ const path = await this.allowed(args.path)
305
+ await mkdir(dirname(path), { recursive: true })
306
+ await writeFile(path, String(args.content ?? ''), 'utf8')
307
+ return { ok: true, value: null }
308
+ }
309
+ case 'fs.mkdir':
310
+ await mkdir(await this.allowed(args.path), { recursive: true })
311
+ return { ok: true, value: null }
312
+ case 'fs.stat': {
313
+ const path = await this.allowed(args.path)
314
+ const info = await stat(path).catch(() => null)
315
+ return {
316
+ ok: true,
317
+ value: { exists: Boolean(info), isDirectory: Boolean(info?.isDirectory()) },
318
+ }
319
+ }
320
+ case 'fs.readdir':
321
+ return { ok: true, value: await readdir(await this.allowed(args.path)) }
322
+ case 'fs.glob':
323
+ return { ok: true, value: await this.#glob(args) }
324
+ default:
325
+ fail('op_unsupported', `不支持的操作:${op}`)
326
+ }
327
+ } catch (error) {
328
+ return {
329
+ ok: false,
330
+ error: {
331
+ code:
332
+ error instanceof NodeOperationError
333
+ ? error.code
334
+ : error?.code === 'ENOENT'
335
+ ? 'not_found'
336
+ : 'op_failed',
337
+ message: String(error?.message || error).slice(0, 500),
338
+ },
339
+ }
340
+ }
341
+ }
342
+
343
+ cancelAll() {
344
+ for (const kill of this.running.values()) kill()
345
+ }
346
+ }
347
+
348
+ // HTTPS 客户端:先取一次大脑的证书,确认指纹与配对时一致,之后把这张证书当作唯一可信的 CA。
349
+ export function normalizeFingerprint(value) {
350
+ const hex = String(value || '')
351
+ .replace(/^SHA256:/i, '')
352
+ .replace(/[^0-9a-f]/gi, '')
353
+ .toUpperCase()
354
+ return hex.length === 64 ? `SHA256:${hex.match(/../g).join(':')}` : ''
355
+ }
356
+
357
+ async function pinnedCertificate(brain, fingerprint) {
358
+ const url = new URL(brain)
359
+ return new Promise((resolveCert, reject) => {
360
+ const socket = tls.connect(
361
+ {
362
+ host: url.hostname.replace(/^\[|\]$/g, ''),
363
+ port: Number(url.port || 443),
364
+ servername: /^[\d.]+$|:/.test(url.hostname) ? undefined : url.hostname,
365
+ rejectUnauthorized: false,
366
+ },
367
+ () => {
368
+ const raw = socket.getPeerCertificate(true)?.raw
369
+ socket.end()
370
+ if (!raw) return reject(new Error('读不到大脑的证书。'))
371
+ const cert = new X509Certificate(raw)
372
+ if (normalizeFingerprint(cert.fingerprint256) !== normalizeFingerprint(fingerprint))
373
+ return reject(new Error('大脑的证书指纹和配对时不一致,已停止连接。'))
374
+ resolveCert(cert.toString())
375
+ },
376
+ )
377
+ socket.setTimeout(15_000, () => socket.destroy(new Error('连接大脑超时。')))
378
+ socket.once('error', reject)
379
+ })
380
+ }
381
+
382
+ export async function createBrainClient({ brain, fingerprint }) {
383
+ const url = new URL(brain)
384
+ if (url.protocol !== 'https:') throw new Error('大脑地址必须是 https://(远程访问地址)。')
385
+ const expected = normalizeFingerprint(fingerprint)
386
+ if (!expected) throw new Error('证书指纹格式不对,应形如 SHA256:AB:CD:…')
387
+ const ca = await pinnedCertificate(brain, expected)
388
+ const agent = new https.Agent({
389
+ ca,
390
+ keepAlive: true,
391
+ // 大脑的自签证书不带这台机器认识的主机名;只认指纹,不认主机名。
392
+ checkServerIdentity: (_host, cert) =>
393
+ normalizeFingerprint(cert.fingerprint256) === expected
394
+ ? undefined
395
+ : new Error('大脑的证书指纹和配对时不一致。'),
396
+ })
397
+ return {
398
+ post(path, body, { token = '', timeoutMs = 30_000 } = {}) {
399
+ const payload = Buffer.from(JSON.stringify(body ?? {}))
400
+ return new Promise((resolveResponse, reject) => {
401
+ const request = https.request(
402
+ new URL(path, url),
403
+ {
404
+ method: 'POST',
405
+ agent,
406
+ headers: {
407
+ 'Content-Type': 'application/json',
408
+ 'Content-Length': payload.length,
409
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
410
+ },
411
+ },
412
+ (response) => {
413
+ const chunks = []
414
+ response.on('data', (chunk) => chunks.push(chunk))
415
+ response.on('end', () => {
416
+ let data = {}
417
+ try {
418
+ data = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
419
+ } catch {}
420
+ if ((response.statusCode || 500) >= 400) {
421
+ const error = new Error(data.error || `大脑返回 ${response.statusCode}`)
422
+ error.status = response.statusCode
423
+ error.code = data.code
424
+ reject(error)
425
+ } else resolveResponse(data)
426
+ })
427
+ },
428
+ )
429
+ request.setTimeout(timeoutMs, () => request.destroy(new Error('请求大脑超时。')))
430
+ request.once('error', reject)
431
+ request.end(payload)
432
+ })
433
+ },
434
+ close() {
435
+ agent.destroy()
436
+ },
437
+ }
438
+ }
439
+
440
+ // 连上大脑干活:一直挂着一次轮询,领到请求就并行执行,结果与输出尽快交回。
441
+ export async function runExecutorLoop({ client, token, node, signal, log = () => {} }) {
442
+ const outbox = []
443
+ let flushing = null
444
+ const flush = async () => {
445
+ if (flushing || !outbox.length) return flushing
446
+ const results = outbox.splice(0, outbox.length)
447
+ flushing = client
448
+ .post('/api/executor/results', { results }, { token })
449
+ .catch((error) => log(`交回结果失败:${error.message}`))
450
+ .finally(() => {
451
+ flushing = null
452
+ if (outbox.length) void flush()
453
+ })
454
+ return flushing
455
+ }
456
+ let flushTimer = null
457
+ const queueChunk = (id, data) => {
458
+ outbox.push({ id, chunk: Buffer.from(data).toString('base64') })
459
+ flushTimer ??= setTimeout(() => {
460
+ flushTimer = null
461
+ void flush()
462
+ }, CHUNK_FLUSH_MS)
463
+ }
464
+ let delay = 1000
465
+ while (!signal?.aborted) {
466
+ try {
467
+ const { requests = [] } = await client.post(
468
+ '/api/executor/poll',
469
+ { ...node.report() },
470
+ { token, timeoutMs: POLL_TIMEOUT_MS },
471
+ )
472
+ delay = 1000
473
+ for (const request of Array.isArray(requests) ? requests : []) {
474
+ void node
475
+ .handle(request, (data) => queueChunk(request.id, data))
476
+ .then((result) => {
477
+ if (request.op === 'cancel') return
478
+ outbox.push({ id: request.id, ...result })
479
+ void flush()
480
+ })
481
+ }
482
+ } catch (error) {
483
+ if (error.status === 401) throw new Error('大脑不认这台电脑了(可能已被移除),请重新配对。')
484
+ log(`连接大脑失败:${error.message},${Math.round(delay / 1000)} 秒后重试。`)
485
+ await new Promise((done) => setTimeout(done, delay))
486
+ delay = Math.min(delay * 2, 30_000)
487
+ }
488
+ }
489
+ node.cancelAll()
490
+ }
491
+
492
+ function parseArgs(argv) {
493
+ const options = { roots: [], shell: true }
494
+ for (let index = 0; index < argv.length; index += 1) {
495
+ const arg = argv[index]
496
+ const next = () => argv[++index] ?? ''
497
+ if (arg === '--brain') options.brain = next()
498
+ else if (arg === '--fingerprint') options.fingerprint = next()
499
+ else if (arg === '--code') options.code = next()
500
+ else if (arg === '--root') options.roots.push(resolve(next()))
501
+ else if (arg === '--name') options.name = next()
502
+ else if (arg === '--no-shell') options.shell = false
503
+ else if (arg === '--help' || arg === '-h') options.help = true
504
+ else if (!options.command) options.command = arg
505
+ else throw new Error(`不认识的参数:${arg}`)
506
+ }
507
+ return options
508
+ }
509
+
510
+ const HELP = `pisper executor —— 让另一台电脑上的 Pisper Bot 在这台电脑上干活
511
+
512
+ pisper executor pair --brain <https 地址> --fingerprint <SHA256:…> --code <配对码> --root <目录> [--root …] [--name 名字] [--no-shell]
513
+ 用 Pisper「Bot › 电脑 › 添加电脑」里显示的配对码配对。只有 --root 列出的目录可以读写。
514
+ pisper executor
515
+ 连上大脑开始干活,Ctrl+C 停止。
516
+
517
+ 配对信息保存在 ${executorHome()}(可用 PISPER_EXECUTOR_HOME 修改)。`
518
+
519
+ async function readConfig(home) {
520
+ try {
521
+ return JSON.parse(await readFile(join(home, 'config.json'), 'utf8'))
522
+ } catch {
523
+ return null
524
+ }
525
+ }
526
+
527
+ export async function executorCommand(argv, { env = process.env, log = console.log } = {}) {
528
+ const options = parseArgs(argv)
529
+ if (options.help) {
530
+ log(HELP)
531
+ return 0
532
+ }
533
+ const home = executorHome(env)
534
+ if (options.command === 'pair') {
535
+ if (!options.brain || !options.fingerprint || !options.code || !options.roots.length)
536
+ throw new Error('配对需要 --brain、--fingerprint、--code 和至少一个 --root。')
537
+ for (const root of options.roots)
538
+ if (!(await stat(root).catch(() => null))?.isDirectory())
539
+ throw new Error(`目录不存在:${root}`)
540
+ const node = new ExecutorNode({
541
+ roots: options.roots,
542
+ worktreeRoot: join(home, 'worktrees'),
543
+ shell: options.shell,
544
+ })
545
+ const client = await createBrainClient(options)
546
+ try {
547
+ const paired = await client.post('/api/executor/pair', {
548
+ code: options.code,
549
+ name: options.name || hostname(),
550
+ ...node.report(),
551
+ })
552
+ await mkdir(home, { recursive: true, mode: 0o700 })
553
+ const config = {
554
+ brain: options.brain,
555
+ fingerprint: normalizeFingerprint(options.fingerprint),
556
+ token: paired.token,
557
+ roots: options.roots,
558
+ shell: options.shell,
559
+ }
560
+ await writeFile(join(home, 'config.json'), `${JSON.stringify(config, null, 2)}\n`, {
561
+ mode: 0o600,
562
+ })
563
+ await chmod(join(home, 'config.json'), 0o600).catch(() => {})
564
+ log(`已配对为「${paired.environment?.name || ''}」。运行 pisper executor 开始干活。`)
565
+ } finally {
566
+ client.close()
567
+ }
568
+ return 0
569
+ }
570
+ if (options.command && options.command !== 'run')
571
+ throw new Error(`不认识的命令:${options.command}`)
572
+ const config = await readConfig(home)
573
+ if (!config?.token)
574
+ throw new Error('还没有配对。先运行 pisper executor pair …(--help 查看用法)')
575
+ const node = new ExecutorNode({
576
+ roots: config.roots,
577
+ worktreeRoot: join(home, 'worktrees'),
578
+ shell: config.shell !== false,
579
+ })
580
+ const client = await createBrainClient(config)
581
+ const controller = new AbortController()
582
+ for (const name of ['SIGINT', 'SIGTERM']) process.once(name, () => controller.abort())
583
+ log(`已连上 ${config.brain},允许的目录:${config.roots.join('、')}。Ctrl+C 停止。`)
584
+ try {
585
+ await runExecutorLoop({ client, token: config.token, node, signal: controller.signal, log })
586
+ } finally {
587
+ client.close()
588
+ }
589
+ return 0
590
+ }
@@ -38,6 +38,7 @@ Usage:
38
38
  pisper doctor [OPTIONS]
39
39
  pisper web [OPTIONS]
40
40
  pisper update [--check]
41
+ pisper executor [pair ...]
41
42
  pisper help [COMMAND]
42
43
 
43
44
  Commands:
@@ -45,6 +46,7 @@ Commands:
45
46
  doctor Check the TUI, Runtime connection, and capability catalogs
46
47
  web Open the bundled Web UI and Provider settings in your browser
47
48
  update Update the complete Pisper distribution through npm
49
+ executor Let a Pisper Bot on another computer work on this one (see pisper executor --help)
48
50
  help Print this help or help for a command
49
51
 
50
52
  Options:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pisper",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "Pisper CLI.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -42,12 +42,12 @@
42
42
  "pisper": {
43
43
  "repository": "ling-kong-ran/pisper",
44
44
  "tuiVersion": "0.6.9",
45
- "runtimeVersion": "0.6.12"
45
+ "runtimeVersion": "0.6.13"
46
46
  },
47
47
  "optionalDependencies": {
48
- "pisper-binary-win32-x64": "npm:pisper@0.6.14-win32-x64.0",
49
- "pisper-binary-darwin-arm64": "npm:pisper@0.6.14-darwin-arm64.0",
50
- "pisper-binary-linux-x64": "npm:pisper@0.6.14-linux-x64.0"
48
+ "pisper-binary-win32-x64": "npm:pisper@0.6.15-win32-x64.0",
49
+ "pisper-binary-darwin-arm64": "npm:pisper@0.6.15-darwin-arm64.0",
50
+ "pisper-binary-linux-x64": "npm:pisper@0.6.15-linux-x64.0"
51
51
  },
52
52
  "publishConfig": {
53
53
  "access": "public",