@steerable/agent-shell 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 (204) hide show
  1. package/LICENSE +91 -0
  2. package/contracts/tool-contract.json +326 -0
  3. package/dist/attachments.d.ts +41 -0
  4. package/dist/attachments.js +147 -0
  5. package/dist/brand.d.ts +24 -0
  6. package/dist/brand.js +92 -0
  7. package/dist/host/http-routes.d.ts +21 -0
  8. package/dist/host/http-routes.js +55 -0
  9. package/dist/host/ipc.d.ts +11 -0
  10. package/dist/host/ipc.js +20 -0
  11. package/dist/host/pack-assembly.d.ts +86 -0
  12. package/dist/host/pack-assembly.js +32 -0
  13. package/dist/host/runtime.d.ts +69 -0
  14. package/dist/host/runtime.js +207 -0
  15. package/dist/host/visible-terminal-exec.d.ts +21 -0
  16. package/dist/host/visible-terminal-exec.js +151 -0
  17. package/dist/hosted-web-search.d.ts +13 -0
  18. package/dist/hosted-web-search.js +82 -0
  19. package/dist/image-attachment.d.ts +39 -0
  20. package/dist/image-attachment.js +133 -0
  21. package/dist/insights/flush.d.ts +10 -0
  22. package/dist/insights/flush.js +139 -0
  23. package/dist/insights/record.d.ts +17 -0
  24. package/dist/insights/record.js +44 -0
  25. package/dist/json-store.d.ts +9 -0
  26. package/dist/json-store.js +22 -0
  27. package/dist/llm/index.d.ts +23 -0
  28. package/dist/llm/index.js +105 -0
  29. package/dist/llm/ollama.d.ts +23 -0
  30. package/dist/llm/ollama.js +242 -0
  31. package/dist/llm/openai-compat.d.ts +20 -0
  32. package/dist/llm/openai-compat.js +199 -0
  33. package/dist/llm/sidecar-provider.d.ts +37 -0
  34. package/dist/llm/sidecar-provider.js +163 -0
  35. package/dist/llm/tool-choice.d.ts +35 -0
  36. package/dist/llm/tool-choice.js +85 -0
  37. package/dist/llm/types.d.ts +122 -0
  38. package/dist/llm/types.js +1 -0
  39. package/dist/local-backend/agent-capability.d.ts +101 -0
  40. package/dist/local-backend/agent-capability.js +174 -0
  41. package/dist/local-backend/ai-title.d.ts +43 -0
  42. package/dist/local-backend/ai-title.js +173 -0
  43. package/dist/local-backend/auto-continue-helper.d.ts +80 -0
  44. package/dist/local-backend/auto-continue-helper.js +83 -0
  45. package/dist/local-backend/branch-helper.d.ts +24 -0
  46. package/dist/local-backend/branch-helper.js +27 -0
  47. package/dist/local-backend/context-compactor.d.ts +81 -0
  48. package/dist/local-backend/context-compactor.js +213 -0
  49. package/dist/local-backend/coreloop-stream.d.ts +245 -0
  50. package/dist/local-backend/coreloop-stream.js +277 -0
  51. package/dist/local-backend/deferred-detector.d.ts +15 -0
  52. package/dist/local-backend/deferred-detector.js +124 -0
  53. package/dist/local-backend/history-helper.d.ts +30 -0
  54. package/dist/local-backend/history-helper.js +34 -0
  55. package/dist/local-backend/interrupted-helper.d.ts +29 -0
  56. package/dist/local-backend/interrupted-helper.js +25 -0
  57. package/dist/local-backend/live-stream.d.ts +36 -0
  58. package/dist/local-backend/live-stream.js +23 -0
  59. package/dist/local-backend/llm-diagnose.d.ts +37 -0
  60. package/dist/local-backend/llm-diagnose.js +284 -0
  61. package/dist/local-backend/message-triggers.d.ts +27 -0
  62. package/dist/local-backend/message-triggers.js +67 -0
  63. package/dist/local-backend/pack-backend-routes.d.ts +31 -0
  64. package/dist/local-backend/pack-backend-routes.js +62 -0
  65. package/dist/local-backend/pack-turn-hooks.d.ts +37 -0
  66. package/dist/local-backend/pack-turn-hooks.js +67 -0
  67. package/dist/local-backend/prompt-builder.d.ts +101 -0
  68. package/dist/local-backend/prompt-builder.js +246 -0
  69. package/dist/local-backend/regenerate-helper.d.ts +62 -0
  70. package/dist/local-backend/regenerate-helper.js +75 -0
  71. package/dist/local-backend/router.d.ts +175 -0
  72. package/dist/local-backend/router.js +3139 -0
  73. package/dist/local-backend/skill-install.d.ts +19 -0
  74. package/dist/local-backend/skill-install.js +71 -0
  75. package/dist/local-backend/skill-loader.d.ts +92 -0
  76. package/dist/local-backend/skill-loader.js +146 -0
  77. package/dist/local-backend/skills/00-identity/SKILL.md +32 -0
  78. package/dist/local-backend/skills/10-goal/SKILL.md +59 -0
  79. package/dist/local-backend/skills/11-loop/SKILL.md +71 -0
  80. package/dist/local-backend/skills/12-create-skill/SKILL.md +88 -0
  81. package/dist/local-backend/skills/70-plan-mode/SKILL.md +58 -0
  82. package/dist/local-backend/skills/80-tool-usage/SKILL.md +70 -0
  83. package/dist/local-backend/skills/81-anti-deferred/SKILL.md +53 -0
  84. package/dist/local-backend/skills/82-data-grounding/SKILL.md +56 -0
  85. package/dist/local-backend/skills/85-local-exec/SKILL.md +86 -0
  86. package/dist/local-backend/skills/86-proactive-coding/SKILL.md +51 -0
  87. package/dist/local-backend/subagent-profiles.d.ts +30 -0
  88. package/dist/local-backend/subagent-profiles.js +74 -0
  89. package/dist/local-backend/task-process.d.ts +12 -0
  90. package/dist/local-backend/task-process.js +176 -0
  91. package/dist/local-backend/task-service.d.ts +135 -0
  92. package/dist/local-backend/task-service.js +565 -0
  93. package/dist/local-backend/turn-duration.d.ts +2 -0
  94. package/dist/local-backend/turn-duration.js +9 -0
  95. package/dist/local-backend/turn-timeline.d.ts +16 -0
  96. package/dist/local-backend/turn-timeline.js +42 -0
  97. package/dist/local-backend/worktree-service.d.ts +84 -0
  98. package/dist/local-backend/worktree-service.js +243 -0
  99. package/dist/local-edit.d.ts +48 -0
  100. package/dist/local-edit.js +44 -0
  101. package/dist/local-executor.d.ts +255 -0
  102. package/dist/local-executor.js +881 -0
  103. package/dist/local-script-registry.d.ts +28 -0
  104. package/dist/local-script-registry.js +63 -0
  105. package/dist/log.d.ts +13 -0
  106. package/dist/log.js +12 -0
  107. package/dist/main.d.ts +1 -0
  108. package/dist/main.js +855 -0
  109. package/dist/mcp-executor.d.ts +45 -0
  110. package/dist/mcp-executor.js +241 -0
  111. package/dist/mcp-server-registry.d.ts +104 -0
  112. package/dist/mcp-server-registry.js +234 -0
  113. package/dist/preload-default.d.ts +1 -0
  114. package/dist/preload-default.js +9 -0
  115. package/dist/preload.cjs +395 -0
  116. package/dist/preload.d.ts +20 -0
  117. package/dist/preload.js +411 -0
  118. package/dist/product-config.d.ts +43 -0
  119. package/dist/product-config.js +26 -0
  120. package/dist/project-registry.d.ts +55 -0
  121. package/dist/project-registry.js +106 -0
  122. package/dist/project-rules.d.ts +15 -0
  123. package/dist/project-rules.js +102 -0
  124. package/dist/runtime.d.ts +62 -0
  125. package/dist/runtime.js +217 -0
  126. package/dist/scenario/pack.d.ts +8 -0
  127. package/dist/scenario/pack.js +1 -0
  128. package/dist/scenario/registry.d.ts +24 -0
  129. package/dist/scenario/registry.js +31 -0
  130. package/dist/server/http-server.d.ts +39 -0
  131. package/dist/server/http-server.js +361 -0
  132. package/dist/server/index.d.ts +1 -0
  133. package/dist/server/index.js +107 -0
  134. package/dist/server/sse-bus.d.ts +14 -0
  135. package/dist/server/sse-bus.js +31 -0
  136. package/dist/shell-adapt.d.ts +21 -0
  137. package/dist/shell-adapt.js +104 -0
  138. package/dist/sidecar/boot.d.ts +36 -0
  139. package/dist/sidecar/boot.js +343 -0
  140. package/dist/sidecar/egress-hint.d.ts +15 -0
  141. package/dist/sidecar/egress-hint.js +46 -0
  142. package/dist/sidecar/egress-proxy.d.ts +183 -0
  143. package/dist/sidecar/egress-proxy.js +419 -0
  144. package/dist/sidecar/errors.d.ts +22 -0
  145. package/dist/sidecar/errors.js +38 -0
  146. package/dist/sidecar/exec-sandbox.d.ts +48 -0
  147. package/dist/sidecar/exec-sandbox.js +94 -0
  148. package/dist/sidecar/handle.d.ts +32 -0
  149. package/dist/sidecar/handle.js +53 -0
  150. package/dist/sidecar/index.d.ts +14 -0
  151. package/dist/sidecar/index.js +13 -0
  152. package/dist/sidecar/proxy-detect.d.ts +50 -0
  153. package/dist/sidecar/proxy-detect.js +182 -0
  154. package/dist/sidecar/reverse-approval.d.ts +55 -0
  155. package/dist/sidecar/reverse-approval.js +86 -0
  156. package/dist/sidecar/reverse-ask-user.d.ts +34 -0
  157. package/dist/sidecar/reverse-ask-user.js +59 -0
  158. package/dist/sidecar/reverse-spawn.d.ts +19 -0
  159. package/dist/sidecar/reverse-spawn.js +161 -0
  160. package/dist/sidecar/reverse-tools.d.ts +29 -0
  161. package/dist/sidecar/reverse-tools.js +106 -0
  162. package/dist/sidecar/safety-patterns.d.ts +41 -0
  163. package/dist/sidecar/safety-patterns.js +157 -0
  164. package/dist/sidecar/storage-path.d.ts +14 -0
  165. package/dist/sidecar/storage-path.js +35 -0
  166. package/dist/sidecar/supervisor.d.ts +218 -0
  167. package/dist/sidecar/supervisor.js +932 -0
  168. package/dist/sidecar/types.d.ts +601 -0
  169. package/dist/sidecar/types.js +1 -0
  170. package/dist/single-instance.d.ts +11 -0
  171. package/dist/single-instance.js +21 -0
  172. package/dist/storage/empty-chats.d.ts +9 -0
  173. package/dist/storage/empty-chats.js +16 -0
  174. package/dist/storage/index.d.ts +373 -0
  175. package/dist/storage/index.js +1158 -0
  176. package/dist/storage/insights-redact.d.ts +2 -0
  177. package/dist/storage/insights-redact.js +30 -0
  178. package/dist/storage/insights-settings.d.ts +53 -0
  179. package/dist/storage/insights-settings.js +92 -0
  180. package/dist/storage/llm-settings.d.ts +120 -0
  181. package/dist/storage/llm-settings.js +233 -0
  182. package/dist/storage/local-store-singleton.d.ts +28 -0
  183. package/dist/storage/local-store-singleton.js +38 -0
  184. package/dist/storage/message-order.d.ts +25 -0
  185. package/dist/storage/message-order.js +27 -0
  186. package/dist/storage/pack-migrations.d.ts +22 -0
  187. package/dist/storage/pack-migrations.js +24 -0
  188. package/dist/storage/pack-seeds.d.ts +36 -0
  189. package/dist/storage/pack-seeds.js +42 -0
  190. package/dist/storage/telemetry-settings.d.ts +38 -0
  191. package/dist/storage/telemetry-settings.js +59 -0
  192. package/dist/storage/usage-summary.d.ts +55 -0
  193. package/dist/storage/usage-summary.js +38 -0
  194. package/dist/storage/web-search-settings.d.ts +38 -0
  195. package/dist/storage/web-search-settings.js +74 -0
  196. package/dist/storage/write-lease.d.ts +26 -0
  197. package/dist/storage/write-lease.js +74 -0
  198. package/dist/terminal-manager.d.ts +83 -0
  199. package/dist/terminal-manager.js +506 -0
  200. package/dist/tool-router.d.ts +228 -0
  201. package/dist/tool-router.js +930 -0
  202. package/dist/tool-search-rank.d.ts +42 -0
  203. package/dist/tool-search-rank.js +96 -0
  204. package/package.json +67 -0
@@ -0,0 +1,881 @@
1
+ import { shellOpenExternal, shellOpenPath } from './runtime.js';
2
+ import { access, mkdir, readFile, rename, stat, writeFile } from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { createHash } from 'crypto';
6
+ import { spawn, execSync } from 'child_process';
7
+ import log from 'electron-log';
8
+ import { constants as fsConstants } from 'fs';
9
+ import { adaptCommandForPowerShell } from './shell-adapt.js';
10
+ import { applyEdits, EditError } from './local-edit.js';
11
+ // `he` ships as CommonJS and does not expose ESM named exports, so we have to
12
+ // take the default import and destructure at runtime in this ESM module.
13
+ import he from 'he';
14
+ const { decode } = he;
15
+ let cachedWineCommand = undefined;
16
+ export function getWineCommand() {
17
+ if (cachedWineCommand !== undefined)
18
+ return cachedWineCommand;
19
+ if (process.platform === 'win32') {
20
+ cachedWineCommand = null;
21
+ return null;
22
+ }
23
+ try {
24
+ try {
25
+ execSync('which wine64', { stdio: 'ignore' });
26
+ cachedWineCommand = 'wine64';
27
+ }
28
+ catch {
29
+ try {
30
+ execSync('which wine', { stdio: 'ignore' });
31
+ cachedWineCommand = 'wine';
32
+ }
33
+ catch {
34
+ cachedWineCommand = null;
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ cachedWineCommand = null;
40
+ }
41
+ return cachedWineCommand;
42
+ }
43
+ export function rewriteExeCommandIfNeeded(command) {
44
+ if (process.platform === 'win32')
45
+ return command;
46
+ if (!command)
47
+ return command;
48
+ const runner = getWineCommand() || 'wine';
49
+ const exeBoundaryRegex = /(^|[&|;({]\s*)\s*("([^"]+\.exe)"|'([^']+\.exe)'|([^\s]+\.exe))/gim;
50
+ return command.replace(exeBoundaryRegex, (match, prefix, fullExePath) => {
51
+ return `${prefix || ''}${runner} ${fullExePath}`;
52
+ });
53
+ }
54
+ /**
55
+ * 依赖名白名单(防 shell 注入——包名会拼进命令行)。不满足即拒绝该参数:
56
+ * - pip:`requests`、`requests[security]>=2.0`、`pandas~=2.2`
57
+ * - npm:`xlsx`、`@scope/pkg`、`pkg@^1.2.3`、`pkg@latest`
58
+ */
59
+ const PIP_PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._\-[\](),=<>!~+]*$/;
60
+ const NPM_PACKAGE_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+(@[A-Za-z0-9._~^<>=+*/xX|-]+)?$/;
61
+ const DEFAULT_TIMEOUT_MS = 30_000;
62
+ const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024;
63
+ const DEFAULT_MAX_READ_SIZE = 1024 * 1024;
64
+ /** 内容版本令牌:UTF-8 内容的 SHA-256。用于 read-before-write 冲突检测。 */
65
+ /**
66
+ * read-before-write 冲突令牌:内容的 SHA-256(UTF-8)十六进制。与框架
67
+ * `steerable_sidecar.file_edit.content_version` 是同一算法(WS4 契约
68
+ * `versionAlgorithm: sha256-utf8-hex`),两侧由共享测试向量防漂移。
69
+ * 导出供 `tests/tool-contract.test.ts` 断言算法一致性。
70
+ */
71
+ export function hashContent(content) {
72
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
73
+ }
74
+ /**
75
+ * P2b 硬门开关(与框架 `STEERABLE_REQUIRE_READ_BEFORE_WRITE` 同名同语义):
76
+ * 默认开启(CC 硬门对齐):对已存在但未在本会话读过(也无显式
77
+ * expectedVersion)的文件做 write/edit 直接拒绝。新建文件永不拦截。
78
+ * 设为 0/false/no/off 可关闭。调用时读取以便测试切换。
79
+ */
80
+ function requireReadBeforeWrite() {
81
+ const v = (process.env.STEERABLE_REQUIRE_READ_BEFORE_WRITE ?? '').trim().toLowerCase();
82
+ return v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
83
+ }
84
+ async function fileExists(filePath) {
85
+ try {
86
+ await access(filePath);
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * GUI 命令(见 isGuiLaunchCommand)没显式给 timeout 时的等待时长:只等一小段
95
+ * 时间捕获"立即启动失败"类错误;到点后按"已启动、仍在运行"成功返回,不杀进程。
96
+ */
97
+ export const GUI_LAUNCH_WAIT_MS = 15_000;
98
+ /**
99
+ * 用户在设置界面配置的"命令默认超时"(毫秒)。null = 用内置 DEFAULT_TIMEOUT_MS。
100
+ * 放在模块级(而不是 LocalStore)是因为本模块被 vitest 直接 import,不能拖入
101
+ * better-sqlite3/electron 依赖;main 进程在启动和保存设置时调用 setter 同步。
102
+ */
103
+ let configuredDefaultTimeoutMs = null;
104
+ export function setDefaultExecTimeoutMs(ms) {
105
+ configuredDefaultTimeoutMs = typeof ms === 'number' && ms >= 1000 ? ms : null;
106
+ }
107
+ export function getDefaultExecTimeoutMs() {
108
+ return configuredDefaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
109
+ }
110
+ /** 用户配置的默认超时;未配置返回 null(调用方可用各自的内置默认)。 */
111
+ export function getConfiguredExecTimeoutMs() {
112
+ return configuredDefaultTimeoutMs;
113
+ }
114
+ /**
115
+ * 判断命令是否在启动 GUI/交互式程序。场景应用启动图形图件的命令行约定
116
+ * 带有 "gui" 字样(如 `xxx.exe gui replay ...`、`--gui`、`start_gui.bat`),
117
+ * 以非字母数字为边界匹配独立的 "gui" token,避免误伤 guide/guid 之类的词。
118
+ *
119
+ * 这类命令的语义是"把程序拉起来给用户操作",等不到退出码是常态——超时后
120
+ * 不应杀进程、更不应判失败重跑(那会把 Qt 图件重复启动多次)。
121
+ */
122
+ export function isGuiLaunchCommand(command) {
123
+ return /(^|[^a-z0-9])gui([^a-z0-9]|$)/i.test(command || '');
124
+ }
125
+ /**
126
+ * 项目模式的路径围栏:判断 resolvedPath 是否落在 root 之内(含 root 本身)。
127
+ * 两个入参都应先经过 resolve(`~` 展开 + path.resolve)——本函数只做纯
128
+ * 字符串/相对路径判断,不碰 fs,方便 ToolRouter 与 LocalExecutor 共用。
129
+ */
130
+ export function isPathWithinRoot(resolvedPath, resolvedRoot) {
131
+ const rel = path.relative(resolvedRoot, resolvedPath);
132
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
133
+ }
134
+ /** 越界错误消息(给模型看,引导它把操作收回项目目录内)。 */
135
+ export function buildProjectRootViolation(resolvedPath, resolvedRoot) {
136
+ return (`路径越界:${resolvedPath} 不在当前对话绑定的项目目录 ${resolvedRoot} 内。` +
137
+ `项目模式下文件操作被限制在项目目录中——请改用项目内的相对/绝对路径;` +
138
+ `如确需访问目录外文件,请告知用户该限制并请其把文件放进项目目录。`);
139
+ }
140
+ const BUILTIN_DANGEROUS = [
141
+ { id: 'rm_rf_root', pattern: 'rm\\s+-rf\\s+\\/(?:\\s|$)', platform: 'unix' },
142
+ { id: 'sudo', pattern: '\\bsudo\\s', platform: 'unix' },
143
+ { id: 'mkfs', pattern: '\\bmkfs\\b', platform: 'unix' },
144
+ { id: 'dd_if', pattern: '\\bdd\\s+if=', platform: 'unix' },
145
+ { id: 'chmod_777_root', pattern: 'chmod\\s+-R\\s+777\\s+\\/(?:\\s|$)', platform: 'unix' },
146
+ { id: 'fork_bomb', pattern: ':\\(\\)\\s*\\{\\s*:\\|:&\\s*\\};:', platform: 'unix' },
147
+ // "format <盘符>:"(含 format.com)才算格式化磁盘;不要误伤 PowerShell
148
+ // 的 Format-List / Format-Table 输出格式化 cmdlet。
149
+ { id: 'win_format_cmd', pattern: '\\bformat(\\.com)?\\s+[a-z]:', platform: 'windows' },
150
+ { id: 'win_del_force', pattern: '\\bdel\\s+\\/f\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
151
+ { id: 'win_rd_force', pattern: '\\brd\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
152
+ ];
153
+ function toWslPath(input) {
154
+ const normalized = input.replace(/\\/g, '/');
155
+ const winDriveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
156
+ if (!winDriveMatch)
157
+ return normalized;
158
+ const drive = winDriveMatch[1].toLowerCase();
159
+ const rest = winDriveMatch[2];
160
+ return `/mnt/${drive}/${rest}`;
161
+ }
162
+ export class LocalExecutor {
163
+ maxOutputBytes;
164
+ defaultShell;
165
+ applyEditsFn;
166
+ wslAvailable = false;
167
+ shellCacheInitialized = false;
168
+ dangerousPatterns = null;
169
+ /** 同文件读-改-写的串行队列(key = resolvedPath)。 */
170
+ fileQueues = new Map();
171
+ /**
172
+ * P2b read-before-write 证据:resolvedPath → 读取时的内容版本
173
+ * (CC `seed_read_state` 对齐)。read 成功时记录;write/edit 缺省
174
+ * expectedVersion 时用它做自动 CAS;写成功后回写新版本。会话恢复时
175
+ * 由 sidecar 的 `read_state.seed` 反向调用重灌(seedReadState)。
176
+ */
177
+ readFileState = new Map();
178
+ constructor(maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, applyEditsFn = applyEdits) {
179
+ this.maxOutputBytes = maxOutputBytes;
180
+ this.applyEditsFn = applyEditsFn;
181
+ this.defaultShell = this.computeDefaultShell();
182
+ }
183
+ updateSafetyConfig(config) {
184
+ const disabled = new Set(config.disabledPatternIds);
185
+ const isWin = process.platform === 'win32';
186
+ const patterns = [];
187
+ for (const def of BUILTIN_DANGEROUS) {
188
+ if (disabled.has(def.id))
189
+ continue;
190
+ const matchesPlatform = def.platform === 'all' || (isWin && def.platform === 'windows') || (!isWin && def.platform === 'unix');
191
+ if (!matchesPlatform)
192
+ continue;
193
+ const flags = def.platform === 'windows' ? 'i' : undefined;
194
+ patterns.push(new RegExp(def.pattern, flags));
195
+ }
196
+ for (const cp of config.customPatterns) {
197
+ if (!cp.enabled)
198
+ continue;
199
+ try {
200
+ patterns.push(new RegExp(cp.pattern));
201
+ }
202
+ catch {
203
+ log.warn(`[local-executor] invalid custom pattern: ${cp.pattern}`);
204
+ }
205
+ }
206
+ this.dangerousPatterns = patterns;
207
+ }
208
+ /**
209
+ * P2b:会话恢复时重灌 read-before-write 证据。sidecar 扫描持久记录里的
210
+ * 文件工具结果,把 path → version 经 `read_state.seed` 推到这里——进程
211
+ * 重启后自动 CAS 仍然成立(CC `seed_read_state` 语义)。键是 sidecar 侧
212
+ * 已解析的绝对路径,原样存储;resolvePath 对绝对路径是恒等,查询能命中。
213
+ * 返回实际收录的条目数(畸形条目跳过)。
214
+ */
215
+ seedReadState(state) {
216
+ let seeded = 0;
217
+ for (const [p, version] of Object.entries(state)) {
218
+ if (typeof p === 'string' && p.length > 0 && typeof version === 'string') {
219
+ this.readFileState.set(p, version);
220
+ seeded += 1;
221
+ }
222
+ }
223
+ return seeded;
224
+ }
225
+ async init() {
226
+ if (this.shellCacheInitialized)
227
+ return;
228
+ if (process.platform === 'win32') {
229
+ this.wslAvailable = await this.detectWslAvailable();
230
+ // On Windows, the default shell should always be powershell.exe
231
+ // to allow native Windows commands and paths by default.
232
+ // WSL is still selectable explicitly by passing shell: 'wsl'.
233
+ this.defaultShell.shell = 'powershell.exe';
234
+ this.defaultShell.args = ['-Command'];
235
+ this.defaultShell.type = 'powershell';
236
+ }
237
+ this.shellCacheInitialized = true;
238
+ }
239
+ getPlatformInfo() {
240
+ return {
241
+ platform: process.platform,
242
+ shell: this.defaultShell.type,
243
+ wslAvailable: this.wslAvailable,
244
+ osVersion: os.version(),
245
+ osArch: os.arch(),
246
+ };
247
+ }
248
+ async executeShell(request) {
249
+ try {
250
+ await this.init();
251
+ let command = request.command?.trim();
252
+ if (!command) {
253
+ return { success: false, error: 'command is required' };
254
+ }
255
+ command = rewriteExeCommandIfNeeded(command);
256
+ const dangerousReason = this.detectDangerousCommand(command);
257
+ if (dangerousReason) {
258
+ return { success: false, error: `Blocked dangerous command: ${dangerousReason}` };
259
+ }
260
+ const resolvedShell = this.resolveShell(request.shell);
261
+ if (resolvedShell.type === 'powershell') {
262
+ // LLM 经常混写 cmd / bash 方言(&&、||、%VAR%);Windows PowerShell 5.1
263
+ // 不支持这些语法,执行前做确定性转换,避免整条命令直接报解析错误。
264
+ const adapted = adaptCommandForPowerShell(command);
265
+ if (adapted !== command) {
266
+ log.info('[local-executor] adapted command for PowerShell', {
267
+ before: command.slice(0, 200),
268
+ after: adapted.slice(0, 200),
269
+ });
270
+ command = adapted;
271
+ }
272
+ // powershell.exe -Command 只回 0/1,会丢掉原生命令的真实退出码
273
+ // (如 python sys.exit(3) 变成 1)。追加 exit $LASTEXITCODE 透传;
274
+ // $LASTEXITCODE 未设置(纯 cmdlet 命令)时等价 exit 0,行为不变。
275
+ command = `${command}\nexit $LASTEXITCODE`;
276
+ }
277
+ // GUI 命令:显式 timeout 仍然尊重;没给的话只等一小段(GUI_LAUNCH_WAIT_MS)
278
+ // ——反正到点会按"已启动"成功返回,等太久只会拖慢 agent 的下一步。
279
+ const guiCommand = isGuiLaunchCommand(command);
280
+ let rawTimeout = request.timeout ?? (guiCommand ? GUI_LAUNCH_WAIT_MS : getDefaultExecTimeoutMs());
281
+ if (rawTimeout > 0 && rawTimeout < 1000) {
282
+ rawTimeout *= 1000;
283
+ }
284
+ const timeout = Math.max(1000, rawTimeout);
285
+ const cwd = this.resolveCwd(request.cwd, resolvedShell.type);
286
+ const env = { ...process.env, ...(request.env || {}) };
287
+ const stdoutChunks = [];
288
+ const stderrChunks = [];
289
+ let stdoutBytes = 0;
290
+ let stderrBytes = 0;
291
+ let truncated = false;
292
+ const args = [...resolvedShell.args, command];
293
+ const child = spawn(resolvedShell.shell, args, {
294
+ cwd,
295
+ env,
296
+ windowsHide: true,
297
+ });
298
+ child.stdout.on('data', (chunk) => {
299
+ if (stdoutBytes >= this.maxOutputBytes) {
300
+ truncated = true;
301
+ return;
302
+ }
303
+ const remain = this.maxOutputBytes - stdoutBytes;
304
+ const safeChunk = chunk.length > remain ? chunk.subarray(0, remain) : chunk;
305
+ stdoutChunks.push(safeChunk);
306
+ stdoutBytes += safeChunk.length;
307
+ if (safeChunk.length < chunk.length)
308
+ truncated = true;
309
+ });
310
+ child.stderr.on('data', (chunk) => {
311
+ if (stderrBytes >= this.maxOutputBytes) {
312
+ truncated = true;
313
+ return;
314
+ }
315
+ const remain = this.maxOutputBytes - stderrBytes;
316
+ const safeChunk = chunk.length > remain ? chunk.subarray(0, remain) : chunk;
317
+ stderrChunks.push(safeChunk);
318
+ stderrBytes += safeChunk.length;
319
+ if (safeChunk.length < chunk.length)
320
+ truncated = true;
321
+ });
322
+ const result = await new Promise(resolve => {
323
+ const timer = setTimeout(() => {
324
+ if (guiCommand) {
325
+ // GUI/交互式命令(命令行带 "gui" token):进程留着继续跑,把
326
+ // "已启动、等用户操作"作为成功结果返回。杀掉或判失败都会诱导
327
+ // LLM 重发启动命令,把 Qt 图件重复拉起多次。
328
+ resolve({
329
+ success: true,
330
+ timedOut: true,
331
+ stillRunning: true,
332
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8') +
333
+ `\n[GUI 程序已启动,仍在运行(等待 ${timeout}ms 后未退出,进程未被终止)。` +
334
+ `请在打开的界面中继续操作;不要重新执行同一条启动命令。]`,
335
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
336
+ truncated,
337
+ shell: resolvedShell.type,
338
+ platform: process.platform,
339
+ });
340
+ return;
341
+ }
342
+ child.kill();
343
+ resolve({
344
+ success: false,
345
+ timedOut: true,
346
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
347
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
348
+ // 超时 ≠ 命令出错。GUI/长任务(如启动 Qt 图件)超时后其窗口进程
349
+ // 往往仍在运行——直接原样重跑启动命令会把程序重复启动多次。
350
+ error: `Command timed out after ${timeout}ms (shell terminated; a launched GUI app may still be running). ` +
351
+ `DO NOT blindly re-run the same launch command — if this launched a GUI/long-running program, ` +
352
+ `first check whether it is already running, or re-run with a larger \`timeout\`, ` +
353
+ `or start it detached (e.g. PowerShell Start-Process) so it does not block.`,
354
+ truncated,
355
+ shell: resolvedShell.type,
356
+ platform: process.platform,
357
+ });
358
+ }, timeout);
359
+ child.on('error', err => {
360
+ clearTimeout(timer);
361
+ resolve({
362
+ success: false,
363
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
364
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
365
+ error: err.message,
366
+ truncated,
367
+ shell: resolvedShell.type,
368
+ platform: process.platform,
369
+ });
370
+ });
371
+ child.on('close', exitCode => {
372
+ clearTimeout(timer);
373
+ resolve({
374
+ success: exitCode === 0,
375
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
376
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
377
+ exitCode: exitCode ?? -1,
378
+ truncated,
379
+ shell: resolvedShell.type,
380
+ platform: process.platform,
381
+ });
382
+ });
383
+ });
384
+ return result;
385
+ }
386
+ catch (error) {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ return { success: false, error: message, platform: process.platform };
389
+ }
390
+ }
391
+ async readLocalFile(request, projectRoot, additionalReadRoots) {
392
+ try {
393
+ const filePath = this.resolvePath(request.path);
394
+ if (projectRoot) {
395
+ const root = this.resolvePath(projectRoot);
396
+ const inAdditionalRoot = (additionalReadRoots ?? []).some((r) => isPathWithinRoot(filePath, this.resolvePath(r)));
397
+ if (!isPathWithinRoot(filePath, root) && !inAdditionalRoot) {
398
+ return { success: false, error: buildProjectRootViolation(filePath, root) };
399
+ }
400
+ }
401
+ const maxSize = request.maxSize ?? DEFAULT_MAX_READ_SIZE;
402
+ const encoding = request.encoding ?? 'utf-8';
403
+ const fileStat = await stat(filePath);
404
+ if (fileStat.size > maxSize) {
405
+ return { success: false, error: `File too large (${fileStat.size} bytes), max=${maxSize}` };
406
+ }
407
+ const content = await readFile(filePath, { encoding });
408
+ const version = hashContent(content);
409
+ // P2b: 记录读证据,后续 write/edit 缺省 expectedVersion 时自动 CAS。
410
+ this.readFileState.set(filePath, version);
411
+ return { success: true, content, version };
412
+ }
413
+ catch (error) {
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ return { success: false, error: message };
416
+ }
417
+ }
418
+ async writeLocalFile(request, projectRoot) {
419
+ const filePath = this.resolvePath(request.path);
420
+ if (projectRoot) {
421
+ const root = this.resolvePath(projectRoot);
422
+ if (!isPathWithinRoot(filePath, root)) {
423
+ return { success: false, error: buildProjectRootViolation(filePath, root) };
424
+ }
425
+ }
426
+ // 同文件串行化:读-改-写必须排队,否则并发写会互相覆盖(pi file-mutation-queue)。
427
+ return this.serializeFileOp(filePath, async () => {
428
+ try {
429
+ const encoding = request.encoding ?? 'utf-8';
430
+ // P2b 自动 CAS:调用方没给 expectedVersion 时回落到本会话的读证据
431
+ // (含 read_state.seed 重灌的)。隐式期望只对仍存在的文件生效——
432
+ // 读后被删除的文件重新写入是新建,没有可覆盖的他人改动。
433
+ const implicit = request.expectedVersion === undefined;
434
+ const expected = request.expectedVersion ?? this.readFileState.get(filePath);
435
+ if (expected !== undefined) {
436
+ const conflict = await this.checkVersion(filePath, expected, encoding, {
437
+ missingOk: implicit,
438
+ });
439
+ if (conflict)
440
+ return { success: false, error: conflict };
441
+ }
442
+ else if (requireReadBeforeWrite() && (await fileExists(filePath))) {
443
+ return {
444
+ success: false,
445
+ error: `未读先写已拒绝:${filePath} 不在本会话的已读记录里` +
446
+ '(STEERABLE_REQUIRE_READ_BEFORE_WRITE 已启用)。请先 local_read_file 再写入。',
447
+ };
448
+ }
449
+ if (request.createDirs) {
450
+ await mkdir(path.dirname(filePath), { recursive: true });
451
+ }
452
+ await this.atomicWrite(filePath, request.content, encoding);
453
+ const version = hashContent(request.content);
454
+ // 写成功后回写读证据:本会话自己的连续写不互相冲突。
455
+ this.readFileState.set(filePath, version);
456
+ return { success: true, version };
457
+ }
458
+ catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ return { success: false, error: message };
461
+ }
462
+ });
463
+ }
464
+ /**
465
+ * local_edit_file 的实现(W6-1):结构化批量编辑。先按 expectedVersion 做
466
+ * read-before-write 冲突检测,再在原文上做三级模糊定位 + 逆序替换,最后
467
+ * 原子落盘。返回新版本令牌与统一 diff。
468
+ */
469
+ async editLocalFile(request, projectRoot) {
470
+ const filePath = this.resolvePath(request.path);
471
+ if (projectRoot) {
472
+ const root = this.resolvePath(projectRoot);
473
+ if (!isPathWithinRoot(filePath, root)) {
474
+ return { success: false, error: buildProjectRootViolation(filePath, root) };
475
+ }
476
+ }
477
+ return this.serializeFileOp(filePath, async () => {
478
+ try {
479
+ const encoding = request.encoding ?? 'utf-8';
480
+ const current = await readFile(filePath, { encoding });
481
+ // P2b 自动 CAS(同 writeLocalFile):缺省 expectedVersion 时用本会话
482
+ // 读证据。edit 的目标必然已存在(上面 readFile 失败即返回),无需
483
+ // missingOk;硬门对 edit 等价于「读过才可改」。
484
+ const expected = request.expectedVersion ?? this.readFileState.get(filePath);
485
+ if (expected !== undefined) {
486
+ const conflict = await this.checkVersion(filePath, expected, encoding);
487
+ if (conflict)
488
+ return { success: false, error: conflict };
489
+ }
490
+ else if (requireReadBeforeWrite()) {
491
+ return {
492
+ success: false,
493
+ error: `未读先改已拒绝:${filePath} 不在本会话的已读记录里` +
494
+ '(STEERABLE_REQUIRE_READ_BEFORE_WRITE 已启用)。请先 local_read_file 再编辑。',
495
+ };
496
+ }
497
+ const result = await this.applyEditsFn(current, request.edits, path.basename(filePath));
498
+ await this.atomicWrite(filePath, result.content, encoding);
499
+ const version = hashContent(result.content);
500
+ this.readFileState.set(filePath, version);
501
+ return {
502
+ success: true,
503
+ version,
504
+ diff: result.diff,
505
+ applied: result.matches.length,
506
+ matches: result.matches.map(m => ({
507
+ level: m.level,
508
+ startLine: m.startLine,
509
+ oldLineCount: m.oldLineCount,
510
+ })),
511
+ };
512
+ }
513
+ catch (error) {
514
+ if (error instanceof EditError) {
515
+ return { success: false, error: error.message };
516
+ }
517
+ const message = error instanceof Error ? error.message : String(error);
518
+ return { success: false, error: message };
519
+ }
520
+ });
521
+ }
522
+ /**
523
+ * read-before-write 冲突检测:当前内容版本与调用方期望不一致时返回错误
524
+ * 消息(引导重新读取),一致返回 null。文件不存在且期望存在也算冲突,
525
+ * 除非 `missingOk`(隐式期望来自读证据:读后被删的文件再写是新建,
526
+ * 没有可覆盖的他人改动)。
527
+ */
528
+ async checkVersion(filePath, expectedVersion, encoding, options) {
529
+ let current;
530
+ try {
531
+ current = await readFile(filePath, { encoding });
532
+ }
533
+ catch {
534
+ if (options?.missingOk)
535
+ return null;
536
+ return `无法冲突检测:读取 ${filePath} 失败(文件可能不存在)。若确认要新建,请去掉 expectedVersion。`;
537
+ }
538
+ const currentVersion = hashContent(current);
539
+ if (currentVersion !== expectedVersion) {
540
+ return (`冲突:${filePath} 在你读取后已被修改(版本令牌不匹配)。` +
541
+ `为避免覆盖他人/其它操作的改动,本次写入已拒绝——请重新 local_read_file 拿到最新内容与 version,再基于它重新编辑。`);
542
+ }
543
+ return null;
544
+ }
545
+ /** 原子写:先写同目录临时文件再 rename(同卷 rename 原子),失败清理临时文件。 */
546
+ async atomicWrite(filePath, content, encoding) {
547
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
548
+ try {
549
+ await writeFile(tmp, content, { encoding });
550
+ await rename(tmp, filePath);
551
+ }
552
+ catch (error) {
553
+ try {
554
+ await writeFile(tmp, '', { encoding }).catch(() => undefined);
555
+ }
556
+ catch {
557
+ /* 忽略清理失败 */
558
+ }
559
+ throw error;
560
+ }
561
+ }
562
+ /**
563
+ * 同文件操作串行队列:把同一 resolvedPath 的读-改-写串成链,避免并发
564
+ * 写互相覆盖。不同文件互不影响。
565
+ */
566
+ serializeFileOp(filePath, fn) {
567
+ const prev = this.fileQueues.get(filePath) ?? Promise.resolve();
568
+ const next = prev.then(fn, fn);
569
+ this.fileQueues.set(filePath, next.then(() => undefined, () => undefined));
570
+ return next;
571
+ }
572
+ /**
573
+ * local_run_snippet 的实现:把代码片段写到 scratch 目录(os.tmpdir()/
574
+ * agent-shell-code),用本机解释器跑起来;可选先装 pip/npm 依赖。
575
+ *
576
+ * 设计要点:
577
+ * - 解释器探测结果缓存(python3 → python → py;node);缺失时返回可操作错误。
578
+ * - pip 优先 `--user`;被 PEP 668(externally-managed,Homebrew Python 常见)
579
+ * 拒绝时在 scratch 目录建一次性 venv,之后所有 python 运行都用 venv 解释器。
580
+ * - npm 依赖装进 scratchDir,node 脚本也落盘在 scratchDir——node 的
581
+ * node_modules 解析会沿脚本所在目录向上找到依赖,无需 NODE_PATH。
582
+ * - cwd 的项目沙箱在 ToolRouter 层完成,这里只负责跑。
583
+ */
584
+ async runCode(request) {
585
+ try {
586
+ await this.init();
587
+ const language = request.language?.trim().toLowerCase();
588
+ const code = request.code ?? '';
589
+ if (!code.trim()) {
590
+ return { success: false, error: 'code is required' };
591
+ }
592
+ if (language !== 'python' && language !== 'node') {
593
+ return {
594
+ success: false,
595
+ error: `不支持的语言: ${request.language}(目前支持 python / node;其他语言请用 local_exec_shell 自行调用)`,
596
+ };
597
+ }
598
+ let interpreter;
599
+ if (language === 'python') {
600
+ interpreter = this.pythonVenvPath ?? (await this.resolveInterpreter('python'));
601
+ if (!interpreter) {
602
+ return {
603
+ success: false,
604
+ language,
605
+ error: '本机未找到 Python 解释器(已探测 python3 / python / py)。' +
606
+ '请先帮用户安装 Python 3(如 `brew install python3` / python.org 安装包),或改用 language: "node"。',
607
+ };
608
+ }
609
+ }
610
+ else {
611
+ interpreter = await this.resolveInterpreter('node');
612
+ if (!interpreter) {
613
+ return {
614
+ success: false,
615
+ language,
616
+ error: '本机未找到 Node.js(node 命令不可用)。' +
617
+ '请先帮用户安装 Node.js(如 `brew install node` / nodejs.org 安装包),或改用 language: "python"。',
618
+ };
619
+ }
620
+ }
621
+ const scratchDir = this.getCodeScratchDir();
622
+ await mkdir(scratchDir, { recursive: true });
623
+ const installed = [];
624
+ if (language === 'python' && request.pipPackages?.length) {
625
+ const bad = request.pipPackages.find((p) => !PIP_PACKAGE_RE.test(p));
626
+ if (bad) {
627
+ return { success: false, language, error: `非法 pip 包名: ${bad}` };
628
+ }
629
+ const install = await this.installPythonPackages(request.pipPackages, scratchDir, interpreter);
630
+ if (!install.success) {
631
+ return { success: false, language, interpreter, error: install.error };
632
+ }
633
+ interpreter = install.python ?? interpreter;
634
+ installed.push(...request.pipPackages);
635
+ }
636
+ if (language === 'node' && request.npmPackages?.length) {
637
+ const bad = request.npmPackages.find((p) => !NPM_PACKAGE_RE.test(p));
638
+ if (bad) {
639
+ return { success: false, language, error: `非法 npm 包名: ${bad}` };
640
+ }
641
+ const pkgs = request.npmPackages.map((p) => `"${p}"`).join(' ');
642
+ const install = await this.executeShell({
643
+ command: `npm install --silent --no-audit --no-fund --prefix "${scratchDir}" ${pkgs}`,
644
+ timeout: 180_000,
645
+ });
646
+ if (!install.success) {
647
+ return {
648
+ success: false,
649
+ language,
650
+ interpreter,
651
+ error: `npm 依赖安装失败:${(install.stderr || install.error || '').slice(0, 1500)}` +
652
+ '。可考虑换用 python 实现,或把失败原因如实告诉用户。',
653
+ };
654
+ }
655
+ installed.push(...request.npmPackages);
656
+ }
657
+ const ext = language === 'python' ? 'py' : 'js';
658
+ const scriptPath = path.join(scratchDir, `snippet-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`);
659
+ await writeFile(scriptPath, code, 'utf-8');
660
+ // Windows 默认 PowerShell:带引号的命令名需要 `&` 调用运算符,否则被当成字符串。
661
+ const callOperator = process.platform === 'win32' ? '& ' : '';
662
+ const result = await this.executeShell({
663
+ command: `${callOperator}"${interpreter}" "${scriptPath}"`,
664
+ cwd: request.cwd,
665
+ timeout: request.timeout,
666
+ });
667
+ return {
668
+ ...result,
669
+ language,
670
+ interpreter,
671
+ scriptPath,
672
+ ...(installed.length > 0 ? { installedPackages: installed } : {}),
673
+ };
674
+ }
675
+ catch (error) {
676
+ const message = error instanceof Error ? error.message : String(error);
677
+ return { success: false, error: message, platform: process.platform };
678
+ }
679
+ }
680
+ getCodeScratchDir() {
681
+ return path.join(os.tmpdir(), 'agent-shell-code');
682
+ }
683
+ /** 解释器探测缓存:undefined=未探测,null=探测过但没有。 */
684
+ pythonCommand = undefined;
685
+ nodeCommand = undefined;
686
+ /** PEP 668 兜底 venv 的解释器路径;一旦建立,后续 python 运行都用它。 */
687
+ pythonVenvPath = null;
688
+ async resolveInterpreter(kind) {
689
+ if (kind === 'python' && this.pythonCommand !== undefined)
690
+ return this.pythonCommand;
691
+ if (kind === 'node' && this.nodeCommand !== undefined)
692
+ return this.nodeCommand;
693
+ const candidates = kind === 'python'
694
+ ? process.platform === 'win32'
695
+ ? ['python', 'py', 'python3']
696
+ : ['python3', 'python']
697
+ : ['node'];
698
+ for (const cmd of candidates) {
699
+ if (await this.probeCommand(cmd, ['--version'])) {
700
+ if (kind === 'python')
701
+ this.pythonCommand = cmd;
702
+ else
703
+ this.nodeCommand = cmd;
704
+ return cmd;
705
+ }
706
+ }
707
+ if (kind === 'python')
708
+ this.pythonCommand = null;
709
+ else
710
+ this.nodeCommand = null;
711
+ return null;
712
+ }
713
+ probeCommand(command, args) {
714
+ return new Promise((resolve) => {
715
+ let child;
716
+ try {
717
+ child = spawn(command, args, { windowsHide: true, stdio: 'ignore' });
718
+ }
719
+ catch {
720
+ resolve(false);
721
+ return;
722
+ }
723
+ child.once('error', () => resolve(false));
724
+ child.once('close', (code) => resolve(code === 0));
725
+ });
726
+ }
727
+ /**
728
+ * pip 安装:`--user` 优先;遇到 PEP 668(externally-managed-environment,
729
+ * Homebrew/系统 Python 的默认限制)就在 scratch 目录建 venv 兜底,
730
+ * 并把 venv 解释器缓存下来供后续运行使用。
731
+ *
732
+ * 返回的 `python` 字段是后续运行脚本应使用的解释器(可能已被 venv 替换)。
733
+ */
734
+ async installPythonPackages(packages, scratchDir, python) {
735
+ const callOperator = process.platform === 'win32' ? '& ' : '';
736
+ const pkgs = packages.map((p) => `"${p}"`).join(' ');
737
+ const inVenv = this.pythonVenvPath !== null;
738
+ const targetPython = this.pythonVenvPath ?? python;
739
+ const userFlag = inVenv ? '' : '--user ';
740
+ const install = await this.executeShell({
741
+ command: `${callOperator}"${targetPython}" -m pip install ${userFlag}--quiet ${pkgs}`,
742
+ timeout: 180_000,
743
+ });
744
+ if (install.success)
745
+ return { success: true, python: targetPython };
746
+ const installError = (install.stderr || install.error || '').slice(0, 1500);
747
+ if (!inVenv && /externally[- ]managed|PEP\s*668/i.test(installError)) {
748
+ const venvDir = path.join(scratchDir, '.venv');
749
+ const create = await this.executeShell({
750
+ command: `${callOperator}"${python}" -m venv "${venvDir}"`,
751
+ timeout: 120_000,
752
+ });
753
+ if (!create.success) {
754
+ return {
755
+ success: false,
756
+ error: `pip --user 被 PEP 668 拒绝,且创建 venv 失败:${(create.stderr || create.error || '').slice(0, 1000)}`,
757
+ };
758
+ }
759
+ const venvPython = process.platform === 'win32'
760
+ ? path.join(venvDir, 'Scripts', 'python.exe')
761
+ : path.join(venvDir, 'bin', 'python');
762
+ const retry = await this.executeShell({
763
+ command: `${callOperator}"${venvPython}" -m pip install --quiet ${pkgs}`,
764
+ timeout: 180_000,
765
+ });
766
+ if (!retry.success) {
767
+ return {
768
+ success: false,
769
+ error: `venv 内 pip 安装失败:${(retry.stderr || retry.error || '').slice(0, 1500)}`,
770
+ };
771
+ }
772
+ this.pythonVenvPath = venvPython;
773
+ return { success: true, python: venvPython };
774
+ }
775
+ return {
776
+ success: false,
777
+ error: `pip 依赖安装失败:${installError}` +
778
+ '。可检查包名/网络,或把失败原因如实告诉用户。',
779
+ };
780
+ }
781
+ async openLocalTarget(request) {
782
+ try {
783
+ const target = decode(request.target).trim();
784
+ if (!target)
785
+ return { success: false, error: 'target is required' };
786
+ const normalizeAppleMapsUrl = (input) => {
787
+ if (!/^(maps:\/\/maps\.apple\.com|https?:\/\/maps\.apple\.com)/i.test(input))
788
+ return null;
789
+ return input.replace(/^maps:\/\//i, 'https://');
790
+ };
791
+ const urlLikeTarget = /^[a-z][a-z0-9+\-.]*:\/\//i.test(target);
792
+ if (urlLikeTarget) {
793
+ const safeTarget = normalizeAppleMapsUrl(target) || target;
794
+ await shellOpenExternal(safeTarget);
795
+ }
796
+ else {
797
+ const localPath = this.resolvePath(target);
798
+ const openError = await shellOpenPath(localPath);
799
+ if (openError)
800
+ return { success: false, error: openError };
801
+ }
802
+ return { success: true };
803
+ }
804
+ catch (error) {
805
+ const message = error instanceof Error ? error.message : String(error);
806
+ return { success: false, error: message };
807
+ }
808
+ }
809
+ /**
810
+ * Public wrapper so other execution paths (e.g. the visible-terminal path
811
+ * in main.ts) can run the same dangerous-command check that headless
812
+ * `executeShell` applies, instead of silently bypassing it.
813
+ */
814
+ detectDangerousCommand(command) {
815
+ return this.detectDangerousCommandInternal(command);
816
+ }
817
+ detectDangerousCommandInternal(command) {
818
+ const patterns = this.dangerousPatterns ?? this.getDefaultPatterns();
819
+ for (const pattern of patterns) {
820
+ if (pattern.test(command))
821
+ return pattern.toString();
822
+ }
823
+ return null;
824
+ }
825
+ getDefaultPatterns() {
826
+ const isWin = process.platform === 'win32';
827
+ return BUILTIN_DANGEROUS.filter(d => d.platform === 'all' || (isWin && d.platform === 'windows') || (!isWin && d.platform === 'unix')).map(d => {
828
+ const flags = d.platform === 'windows' ? 'i' : undefined;
829
+ return new RegExp(d.pattern, flags);
830
+ });
831
+ }
832
+ resolvePath(inputPath) {
833
+ const expanded = inputPath.startsWith('~') ? path.join(os.homedir(), inputPath.slice(1)) : inputPath;
834
+ return path.resolve(expanded);
835
+ }
836
+ resolveCwd(cwd, shellType) {
837
+ const resolved = this.resolvePath(cwd || os.homedir());
838
+ if (process.platform === 'win32' && shellType === 'wsl') {
839
+ return toWslPath(resolved);
840
+ }
841
+ return resolved;
842
+ }
843
+ resolveShell(shellType) {
844
+ if (!shellType)
845
+ return this.defaultShell;
846
+ if (shellType === this.defaultShell.type)
847
+ return this.defaultShell;
848
+ if (shellType === 'zsh')
849
+ return { shell: '/bin/zsh', args: ['-l', '-c'], type: 'zsh' };
850
+ if (shellType === 'bash')
851
+ return { shell: '/bin/bash', args: ['-l', '-c'], type: 'bash' };
852
+ if (shellType === 'wsl')
853
+ return { shell: 'wsl.exe', args: ['bash', '-c'], type: 'wsl' };
854
+ if (shellType === 'cmd')
855
+ return { shell: 'cmd.exe', args: ['/c'], type: 'cmd' };
856
+ return { shell: 'powershell.exe', args: ['-Command'], type: 'powershell' };
857
+ }
858
+ computeDefaultShell() {
859
+ if (process.platform === 'darwin')
860
+ return { shell: '/bin/zsh', args: ['-l', '-c'], type: 'zsh' };
861
+ if (process.platform === 'linux')
862
+ return { shell: '/bin/bash', args: ['-l', '-c'], type: 'bash' };
863
+ if (process.platform === 'win32')
864
+ return { shell: 'powershell.exe', args: ['-Command'], type: 'powershell' };
865
+ return { shell: '/bin/sh', args: ['-c'], type: 'bash' };
866
+ }
867
+ async detectWslAvailable() {
868
+ try {
869
+ await access('C:\\Windows\\System32\\wsl.exe', fsConstants.X_OK);
870
+ const result = await new Promise(resolve => {
871
+ const child = spawn('wsl.exe', ['--status'], { windowsHide: true });
872
+ child.once('error', () => resolve(false));
873
+ child.once('close', code => resolve(code === 0));
874
+ });
875
+ return result;
876
+ }
877
+ catch {
878
+ return false;
879
+ }
880
+ }
881
+ }