@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,419 @@
1
+ /**
2
+ * W1.3.3:桌面宿主内嵌 `steerable_egress_proxy` 的启动器。
3
+ *
4
+ * 默认态势(3.1a,2026-09-08 起默认开):sidecar 出网收敛到本机代理,
5
+ * Seatbelt 列表只放行代理端口,真正的主机名单由代理进程持有(代理跑在
6
+ * 沙箱外)。显式 `STEERABLE_EGRESS_PROXY=0` 退出,回退到 Seatbelt 允许
7
+ * 列表从 provider baseUrl 派生、主机名退化为端口级的旧态势(sbpl 限制,
8
+ * 见 docs/spec/safety.md)。
9
+ *
10
+ * 自动回退:检测到 ambient/系统代理(collectAmbientProxyEndpoints 非空)
11
+ * 时不启动——代理解析 CONNECT 后直连目标,没有上游代理链,系统代理劫持
12
+ * 类网络开了反而断网。回退路径就是旧的 Seatbelt 端口级派生(其允许列表
13
+ * 会并入 ambient proxy 端点,见 boot.ts 的 W4-8 注释)。
14
+ *
15
+ * v1 限制(与框架 egress-proxy 的 v1 范围一致):
16
+ * - 只服务 CONNECT(HTTPS 隧道)。plain-http 端点(如本机 Ollama)不进
17
+ * 代理,直接留在 Seatbelt 列表里。
18
+ * - 代理直连目标主机,无上游代理链。
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { connect, createServer } from 'node:net';
23
+ import { defaultSearchBaseUrl } from '../storage/web-search-settings.js';
24
+ /**
25
+ * `baseUrl` 对应的允许条目形态:裸主机(框架侧隐含 443/80)或 `host:port`。
26
+ *
27
+ * `proxied` 为 false 表示 plain-http 端点——它们不走代理,留在沙箱 profile
28
+ * 的直连列表里。解析不出主机名时返回 null。boot 时的名单派生与运行期的会话
29
+ * 放行共用这一处,两者的条目语义不会分叉。
30
+ */
31
+ export function egressAllowEntry(baseUrl) {
32
+ if (!baseUrl)
33
+ return null;
34
+ let url;
35
+ try {
36
+ url = new URL(baseUrl);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ if (!url.hostname)
42
+ return null;
43
+ return {
44
+ entry: url.port ? `${url.hostname}:${url.port}` : url.hostname,
45
+ host: url.hostname,
46
+ port: url.port,
47
+ proxied: url.protocol === 'https:',
48
+ };
49
+ }
50
+ /**
51
+ * 从 provider baseUrl 构建代理计划。https 端点进代理名单;http 端点留在
52
+ * 直连列表。派生不出任何代理条目(无 https 端点且无 web 域名)时返回
53
+ * null(调用方回退到旧的 Seatbelt 派生路径——空名单的代理没有意义,
54
+ * 框架侧也会 fail loud)。
55
+ */
56
+ export function buildEgressProxyPlan(options) {
57
+ const { pythonExecutable, port, providerBaseUrl, providerApiKey } = options;
58
+ const proxiedHosts = [];
59
+ const directHosts = [];
60
+ let brokerHost = null;
61
+ // unparseable baseUrl → no derivation, same as the legacy path
62
+ const provider = egressAllowEntry(providerBaseUrl);
63
+ if (provider) {
64
+ if (provider.proxied) {
65
+ proxiedHosts.push(provider.entry);
66
+ // 凭证注入按裸主机匹配(转发目标是 443);显式端口端点不进
67
+ // broker——注入规则与 CONNECT 名单语义保持各自单一。
68
+ if (!provider.port && providerApiKey)
69
+ brokerHost = provider.host;
70
+ }
71
+ else {
72
+ directHosts.push(provider.entry);
73
+ }
74
+ }
75
+ for (const host of options.webAllowedHosts ?? []) {
76
+ if (!proxiedHosts.includes(host))
77
+ proxiedHosts.push(host);
78
+ }
79
+ if (proxiedHosts.length === 0)
80
+ return null;
81
+ const proxyEndpoint = `127.0.0.1:${port}`;
82
+ const broker = brokerHost
83
+ ? {
84
+ host: brokerHost,
85
+ secretEnv: 'STEERABLE_EGRESS_SECRET',
86
+ secretValue: `Bearer ${providerApiKey}`,
87
+ }
88
+ : undefined;
89
+ const control = {
90
+ tokenEnv: 'STEERABLE_EGRESS_CONTROL_TOKEN',
91
+ tokenValue: randomBytes(24).toString('base64url'),
92
+ };
93
+ return {
94
+ command: pythonExecutable,
95
+ args: [
96
+ '-m',
97
+ 'steerable_egress_proxy',
98
+ '--bind',
99
+ proxyEndpoint,
100
+ ...proxiedHosts.flatMap((host) => ['--allow', host]),
101
+ // 控制端口 0 = ephemeral;实际端口由代理 stdout 的
102
+ // EGRESS_CONTROL_PORT= 行读回(startEgressProxy 解析)。
103
+ '--control-port',
104
+ '0',
105
+ '--control-token-env',
106
+ control.tokenEnv,
107
+ ...(broker
108
+ ? [
109
+ '--inject-host',
110
+ broker.host,
111
+ '--inject-secret-env',
112
+ broker.secretEnv,
113
+ ]
114
+ : []),
115
+ ],
116
+ port,
117
+ proxyEndpoint,
118
+ proxyUrl: `http://${proxyEndpoint}`,
119
+ sandboxAllowedHosts: [proxyEndpoint, ...directHosts],
120
+ proxiedHosts,
121
+ broker,
122
+ control,
123
+ };
124
+ }
125
+ /**
126
+ * 3.1b/3.1d:推导应并入代理 CONNECT 白名单的 web 出网主机。
127
+ *
128
+ * 数据源与 sidecar 应用层完全一致(同源):`STEERABLE_WEB_ALLOWED_DOMAINS`
129
+ * 进程环境(sidecar 继承同一 env 做 domain_policy_error),规范化也与
130
+ * WebToolsConfig 相同(小写、去前导点)。web_search 在 sidecar 进程内
131
+ * 执行时(搜索 key 存在,或 provider=ddg),其固定 API 端点一并加入——否则
132
+ * CONNECT api.tavily.com:443 / html.duckduckgo.com:443 会被代理 403。
133
+ * provider=host 的托管搜索在 Electron 主进程执行,不占 sidecar 出网。
134
+ *
135
+ * 应用层空名单的语义是"任意公网",代理白名单不能是开放的,所以空名单
136
+ * 不并入任何条目——此时 web_fetch 在 confined 下对名单外目标被代理
137
+ * 403,sidecar 侧错误信息已带指向本名单的提示。
138
+ */
139
+ export function deriveWebEgressHosts(options) {
140
+ if (!options.webTools)
141
+ return [];
142
+ const { env, searchEnv } = options;
143
+ const hosts = [];
144
+ for (const entry of (env.STEERABLE_WEB_ALLOWED_DOMAINS ?? '').split(',')) {
145
+ const domain = entry.trim().toLowerCase().replace(/^\.+/, '');
146
+ if (domain)
147
+ hosts.push(domain);
148
+ }
149
+ if (searchEnv.STEERABLE_WEB_SEARCH_API_KEY || searchEnv.STEERABLE_WEB_SEARCH_PROVIDER === 'ddg') {
150
+ // 与 WebToolsConfig.resolve 的 search_base_url 推导保持一致:显式
151
+ // STEERABLE_WEB_SEARCH_BASE_URL 优先,否则按 provider 取默认端点。
152
+ // ddg 无钥也在 sidecar 进程内执行,必须放行 html.duckduckgo.com。
153
+ const explicit = (env.STEERABLE_WEB_SEARCH_BASE_URL ?? '').trim();
154
+ const provider = (searchEnv.STEERABLE_WEB_SEARCH_PROVIDER
155
+ || env.STEERABLE_WEB_SEARCH_PROVIDER
156
+ || '').trim();
157
+ const base = explicit || defaultSearchBaseUrl(provider);
158
+ try {
159
+ const url = new URL(base);
160
+ // 显式端口保留(裸主机只放行 443/80,带端口的端点要精确条目)。
161
+ const entry = url.port ? `${url.hostname}:${url.port}` : url.hostname;
162
+ if (entry)
163
+ hosts.push(entry);
164
+ }
165
+ catch {
166
+ /* 畸形 base url:sidecar 侧 resolve 同样失败,web_search 不会注册 */
167
+ }
168
+ }
169
+ // 框架代理对畸形 --allow 条目 fail loud(拒启 → 整体回退端口级)。在
170
+ // 这里过滤保住 LLM 通路的按主机管控;被丢的条目本就无法匹配真实主机,
171
+ // 应用层名单语义不变。形态与框架 parse_allow_entry 的字母表一致
172
+ // (host 或 host:port;域名已在上面小写化)。
173
+ return hosts.filter((host) => /^[a-z0-9._-]+(:[0-9]{1,5})?$/.test(host));
174
+ }
175
+ /**
176
+ * 当前活跃的凭证代理(broker 模式开启时非空)。router 据此改写
177
+ * chat.stream 的 baseUrl/apiKey。模块级状态:代理由 main.ts 拥有,
178
+ * 流参数构造在 router.ts,二者经此 getter 会面。
179
+ */
180
+ let activeBroker = null;
181
+ export function getActiveEgressBroker() {
182
+ return activeBroker;
183
+ }
184
+ let egressPosture = null;
185
+ export function recordEgressPosture(posture) {
186
+ egressPosture = posture;
187
+ }
188
+ /**
189
+ * 起不起按主机代理——在派生名单、起进程之前就能定的那部分判断。
190
+ *
191
+ * 两种不起:显式关闭,以及检测到 ambient/系统代理(本代理解析 CONNECT 后
192
+ * 直连目标,没有上游代理链,在系统代理劫持的网络里开了反而断网)。两者的
193
+ * `reason` 是设置页「安全」区直接渲染的文案,所以在这里定形并被测试锁住。
194
+ *
195
+ * 派生不出代理条目、或进程启动失败,要等到那两步才知道,仍由调用方处理。
196
+ */
197
+ export function decideEgressProxy(options) {
198
+ if (options.env.STEERABLE_EGRESS_PROXY === '0') {
199
+ return {
200
+ start: false,
201
+ posture: { mode: 'disabled', reason: '已通过 STEERABLE_EGRESS_PROXY=0 关闭' },
202
+ };
203
+ }
204
+ if (options.ambientProxies.length > 0) {
205
+ return {
206
+ start: false,
207
+ posture: {
208
+ mode: 'port-only-fallback',
209
+ reason: `检测到系统/环境代理(${options.ambientProxies.join(', ')}),按主机管控已退回端口级`,
210
+ },
211
+ };
212
+ }
213
+ return { start: true };
214
+ }
215
+ export function getEgressPosture() {
216
+ return egressPosture;
217
+ }
218
+ /**
219
+ * 当前活跃代理的 `127.0.0.1:<port>` 端点(无论是否 broker 模式)。P3.2
220
+ * shell-via-proxy:chat 的 execSandbox 在代理活跃时把 allowedHosts 收敛到
221
+ * 这个 localhost 端点,Seatbelt 据此判 enforcement="full",requireFull
222
+ * 才能缺省开。代理未运行时为 null(回退到 baseUrl 派生的 partial 路径)。
223
+ */
224
+ let activeProxyEndpoint = null;
225
+ export function getActiveEgressProxyEndpoint() {
226
+ return activeProxyEndpoint;
227
+ }
228
+ /**
229
+ * 活跃代理的控制端点(loopback-only `POST /allow` + Bearer)。token 只驻留
230
+ * 主进程内存,与下发给 sidecar 的是同一个值。
231
+ */
232
+ let activeControl = null;
233
+ /**
234
+ * 把 `baseUrl` 的主机加进运行中代理的会话白名单。
235
+ *
236
+ * LLM 端点不是一条策略判断:白名单本来就是从用户正在编辑的那一项设置派生
237
+ * 出来的,所以它必须跟着那项设置走,而不是停在 boot 时的取值上。否则用户
238
+ * 换网关后整个会话(连聊天)都被自己的代理 403,只有重启才好——测试按钮
239
+ * 更是必然被拒,因为它验的正是尚未保存的地址。
240
+ *
241
+ * 只有人能到达这里:调用点是设置页的 models.list 与保存路由,模型的工具面
242
+ * 没有这两者,`web_fetch` 的 SSRF 预检也拒绝回环地址。web 那半边的名单不受
243
+ * 影响,被注入的模型照样连不上名单外的主机。
244
+ *
245
+ * 放行是会话域的(代理进程退出即失效),条目形态与 `buildEgressProxyPlan`
246
+ * 的派生一致,两处语义不会分叉。
247
+ *
248
+ * @returns 已放行为 true;无可放行之处为 false——代理没在跑、URL 解析不了,
249
+ * 或端点是 plain-http(那类端点不进代理,由 spawn 时的沙箱 profile 钉死,
250
+ * 运行期改不了,仍需重启)。
251
+ */
252
+ export async function allowEgressForBaseUrl(baseUrl) {
253
+ const control = activeControl;
254
+ if (!control)
255
+ return false;
256
+ const target = egressAllowEntry(baseUrl);
257
+ if (!target || !target.proxied)
258
+ return false;
259
+ const entry = target.entry;
260
+ try {
261
+ const response = await fetch(`http://127.0.0.1:${control.port}/allow`, {
262
+ method: 'POST',
263
+ headers: {
264
+ Authorization: `Bearer ${control.token}`,
265
+ 'Content-Type': 'application/json',
266
+ },
267
+ body: JSON.stringify({ host: entry }),
268
+ signal: AbortSignal.timeout(5_000),
269
+ });
270
+ if (!response.ok) {
271
+ control.log(`control endpoint refused ${entry}: ${response.status}`);
272
+ return false;
273
+ }
274
+ control.log(`session allow added for ${entry}`);
275
+ return true;
276
+ }
277
+ catch (err) {
278
+ control.log(`control endpoint unreachable for ${entry}: ${err instanceof Error ? err.message : String(err)}`);
279
+ return false;
280
+ }
281
+ }
282
+ /**
283
+ * 启动代理并等它就绪后再放行 sidecar 启动——sidecar 的 LLM 路径要经过它,
284
+ * 代理未就绪就启动 sidecar 等于把首轮对话送进必失败的黑洞。
285
+ *
286
+ * 就绪判定用 TCP 连接探针而不是解析 stdout:框架 v1 的就绪日志走
287
+ * logging(默认不输出),把宿主耦合到日志文案上太脆。端口是我们选的,
288
+ * connect 成功 = 已在监听。
289
+ */
290
+ export async function startEgressProxy(plan, onLog, readyTimeoutMs = 10_000) {
291
+ const child = spawn(plan.command, plan.args, {
292
+ stdio: ['ignore', 'pipe', 'pipe'],
293
+ // 凭证与控制 token 经 env 传入代理进程——argv 在 ps 里可见,env 不入
294
+ // 命令行。
295
+ env: plan.broker
296
+ ? {
297
+ ...process.env,
298
+ [plan.broker.secretEnv]: plan.broker.secretValue,
299
+ ...(plan.control ? { [plan.control.tokenEnv]: plan.control.tokenValue } : {}),
300
+ }
301
+ : plan.control
302
+ ? { ...process.env, [plan.control.tokenEnv]: plan.control.tokenValue }
303
+ : process.env,
304
+ });
305
+ // 控制端口是 ephemeral 的:代理由 stdout 报回实际端口。就绪条件在
306
+ // TCP 探针之外加上这一行(控制面开启时),否则 sidecar 拿到的
307
+ // STEERABLE_EGRESS_CONTROL_PORT 可能是尚未监听的端口。
308
+ let controlPort = null;
309
+ let resolveControlLine = () => { };
310
+ const controlLineSeen = new Promise((resolve) => {
311
+ resolveControlLine = resolve;
312
+ });
313
+ child.stdout.on('data', (chunk) => {
314
+ for (const line of chunk.toString().split('\n')) {
315
+ if (!line.trim())
316
+ continue;
317
+ const match = /^EGRESS_CONTROL_PORT=(\d+)$/.exec(line.trim());
318
+ if (match) {
319
+ controlPort = Number(match[1]);
320
+ resolveControlLine();
321
+ }
322
+ onLog(line);
323
+ }
324
+ });
325
+ child.stderr.on('data', (chunk) => {
326
+ for (const line of chunk.toString().split('\n'))
327
+ if (line.trim())
328
+ onLog(line);
329
+ });
330
+ const ready = new Promise((resolve, reject) => {
331
+ let settled = false;
332
+ const timer = setTimeout(() => {
333
+ fail(new Error(`egress proxy did not listen on ${plan.proxyEndpoint} within ${readyTimeoutMs}ms`));
334
+ }, readyTimeoutMs);
335
+ const succeed = () => {
336
+ if (settled)
337
+ return;
338
+ settled = true;
339
+ clearTimeout(timer);
340
+ resolve();
341
+ };
342
+ const fail = (err) => {
343
+ if (settled)
344
+ return;
345
+ settled = true;
346
+ clearTimeout(timer);
347
+ reject(err);
348
+ };
349
+ child.on('exit', (code) => fail(new Error(`egress proxy exited before ready (code ${code})`)));
350
+ const probe = () => {
351
+ if (settled)
352
+ return;
353
+ const socket = connect(plan.port, '127.0.0.1');
354
+ socket.once('connect', () => {
355
+ socket.destroy();
356
+ succeed();
357
+ });
358
+ socket.once('error', () => {
359
+ socket.destroy();
360
+ setTimeout(probe, 100);
361
+ });
362
+ };
363
+ probe();
364
+ });
365
+ try {
366
+ await ready;
367
+ if (plan.control) {
368
+ await Promise.race([
369
+ controlLineSeen,
370
+ new Promise((_, reject) => setTimeout(() => reject(new Error('egress proxy did not report its control port')), readyTimeoutMs)),
371
+ ]);
372
+ }
373
+ }
374
+ catch (err) {
375
+ child.kill('SIGTERM');
376
+ throw err;
377
+ }
378
+ activeBroker = plan.broker
379
+ ? { host: plan.broker.host, proxyUrl: plan.proxyUrl }
380
+ : null;
381
+ activeProxyEndpoint = plan.proxyEndpoint;
382
+ activeControl =
383
+ plan.control && controlPort
384
+ ? { port: controlPort, token: plan.control.tokenValue, log: onLog }
385
+ : null;
386
+ let stopping = false;
387
+ child.on('exit', (code, signal) => {
388
+ activeBroker = null;
389
+ activeProxyEndpoint = null;
390
+ activeControl = null;
391
+ if (!stopping) {
392
+ onLog(`egress proxy exited unexpectedly (code ${code}, signal ${signal})`);
393
+ }
394
+ });
395
+ return {
396
+ plan,
397
+ controlPort,
398
+ stop: () => {
399
+ stopping = true;
400
+ activeBroker = null;
401
+ activeProxyEndpoint = null;
402
+ activeControl = null;
403
+ if (!child.killed)
404
+ child.kill('SIGTERM');
405
+ },
406
+ };
407
+ }
408
+ /** 让系统分配一个空闲回环端口(listen(0) 后立即释放,供代理绑定)。 */
409
+ export async function pickFreePort() {
410
+ return new Promise((resolve, reject) => {
411
+ const server = createServer();
412
+ server.once('error', reject);
413
+ server.listen(0, '127.0.0.1', () => {
414
+ const address = server.address();
415
+ const port = typeof address === 'object' && address ? address.port : 0;
416
+ server.close(() => (port > 0 ? resolve(port) : reject(new Error('no free port'))));
417
+ });
418
+ });
419
+ }
@@ -0,0 +1,22 @@
1
+ import type { SidecarSandboxPosture } from './types.js';
2
+ /** Failed to spawn or hand-shake the sidecar. */
3
+ export declare class SidecarBootError extends Error {
4
+ readonly cause?: unknown | undefined;
5
+ constructor(message: string, cause?: unknown | undefined);
6
+ }
7
+ /** A JSON-RPC method call returned an error frame. */
8
+ export declare class SidecarMethodError extends Error {
9
+ readonly code: number;
10
+ readonly kind: string | undefined;
11
+ readonly data: unknown;
12
+ constructor(message: string, code: number, kind: string | undefined, data: unknown);
13
+ }
14
+ /** Sidecar exited or stopped responding before a request completed. */
15
+ export declare class SidecarShutdownError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ /** Layer-1 confinement was requested and no backend could wrap the process. */
19
+ export declare class SidecarSandboxUnavailableError extends SidecarBootError {
20
+ readonly posture: SidecarSandboxPosture;
21
+ constructor(message: string, posture: SidecarSandboxPosture, cause?: unknown);
22
+ }
@@ -0,0 +1,38 @@
1
+ /** Failed to spawn or hand-shake the sidecar. */
2
+ export class SidecarBootError extends Error {
3
+ cause;
4
+ constructor(message, cause) {
5
+ super(message);
6
+ this.cause = cause;
7
+ this.name = 'SidecarBootError';
8
+ }
9
+ }
10
+ /** A JSON-RPC method call returned an error frame. */
11
+ export class SidecarMethodError extends Error {
12
+ code;
13
+ kind;
14
+ data;
15
+ constructor(message, code, kind, data) {
16
+ super(message);
17
+ this.code = code;
18
+ this.kind = kind;
19
+ this.data = data;
20
+ this.name = 'SidecarMethodError';
21
+ }
22
+ }
23
+ /** Sidecar exited or stopped responding before a request completed. */
24
+ export class SidecarShutdownError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = 'SidecarShutdownError';
28
+ }
29
+ }
30
+ /** Layer-1 confinement was requested and no backend could wrap the process. */
31
+ export class SidecarSandboxUnavailableError extends SidecarBootError {
32
+ posture;
33
+ constructor(message, posture, cause) {
34
+ super(message, cause);
35
+ this.posture = posture;
36
+ this.name = 'SidecarSandboxUnavailableError';
37
+ }
38
+ }
@@ -0,0 +1,48 @@
1
+ import type { SidecarSupervisor } from './supervisor.js';
2
+ import type { SidecarChatStreamRequest } from './types.js';
3
+ export type ExecSandboxEnforcement = 'full' | 'partial' | 'none';
4
+ /** 会话级命令沙箱档:工作区围栏 vs 关闭逐条命令沙箱。 */
5
+ export type ExecPolicy = 'workspace' | 'full';
6
+ export declare function parseExecPolicy(value: unknown): ExecPolicy;
7
+ export interface ExecSandboxCapability {
8
+ backend: string;
9
+ enforcement: ExecSandboxEnforcement;
10
+ }
11
+ /**
12
+ * 一个回合发出的 layer-3 参数。字段全必填——请求类型把它们都设成可选是为了
13
+ * 让宿主省略整段,而桌面永远发全套,`worldState` 的权限披露也直接读这里。
14
+ */
15
+ export type ExecSandboxParams = Required<Omit<NonNullable<SidecarChatStreamRequest['execSandbox']>, 'allowedHosts'>> & {
16
+ allowedHosts: string[] | undefined;
17
+ };
18
+ /**
19
+ * W4-2/W4-3:从 provider baseUrl 派生出egress 允许列表条目——代理未启用时
20
+ * agent 唯一合法的出网对端。baseUrl 缺失/不可解析时返回 undefined(沙箱保持
21
+ * 出网开放;加固永远不能弄断 LLM 通路)。与 layer-1 同样的 sbpl 限制:远端
22
+ * 条目退化为端口级强制。
23
+ */
24
+ export declare function deriveEgressAllowListFromBaseUrl(baseUrl: string | undefined): string[] | undefined;
25
+ export declare function getExecSandboxCapability(): ExecSandboxCapability | null;
26
+ /**
27
+ * 用回合真实会发的 egress 参数问 sidecar:这台机器的后端到底能达到哪一档
28
+ * 强制。boot 时问一次即可——后端选择只随平台与 egress 变化,两者在一次进程
29
+ * 生命周期内都是定的。
30
+ *
31
+ * 探测失败按「未知能力」处理(保持 null),`buildExecSandbox` 于是不要求
32
+ * full。宁可少收紧一档,也不能因为一次 RPC 抖动把整个 shell 面判死。
33
+ */
34
+ export declare function probeExecSandboxCapability(supervisor: SidecarSupervisor): Promise<void>;
35
+ /**
36
+ * 一个回合的 `execSandbox` 参数。
37
+ *
38
+ * `requireFull` 只在探测确认这台机器真能达到 full 时才开。它会在执行前拒掉
39
+ * 所有达不到 full 的调用,而带 egress 时只有 Seatbelt 能报 full(它按主机
40
+ * 钉死)——bwrap 与 Landlock 没有按主机管控、Windows 没有改写后端。从平台
41
+ * 猜测推导过一次,结果是代理一开、Linux 上每一次 shell 调用都被拒。
42
+ *
43
+ * `requireBackend` 在沙箱开启时始终开:请求了收容就不能因为没有后端而裸跑。
44
+ * `policy: 'full'` 关闭这一层(enabled/requireBackend 都为 false)。
45
+ */
46
+ export declare function buildExecSandbox(writableRoots: string[], options?: {
47
+ policy?: ExecPolicy;
48
+ }): ExecSandboxParams;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Layer-3(逐 exec 沙箱)的桌面态势:`execSandbox` 请求参数的唯一构造点。
3
+ *
4
+ * 聊天回合(router.ts)与任务回合(task-service.ts)发的是同一份态势,只有
5
+ * 可写根不同。两边各建一份的时候,`requireFull` 在其中一边改错就只在另一边
6
+ * 暴露——所以这里收成一个 `buildExecSandbox`。
7
+ */
8
+ import log from 'electron-log';
9
+ import { llmService } from '../llm/index.js';
10
+ import { getActiveEgressProxyEndpoint } from './egress-proxy.js';
11
+ export function parseExecPolicy(value) {
12
+ return value === 'full' ? 'full' : 'workspace';
13
+ }
14
+ /**
15
+ * W4-2/W4-3:从 provider baseUrl 派生出egress 允许列表条目——代理未启用时
16
+ * agent 唯一合法的出网对端。baseUrl 缺失/不可解析时返回 undefined(沙箱保持
17
+ * 出网开放;加固永远不能弄断 LLM 通路)。与 layer-1 同样的 sbpl 限制:远端
18
+ * 条目退化为端口级强制。
19
+ */
20
+ export function deriveEgressAllowListFromBaseUrl(baseUrl) {
21
+ if (!baseUrl)
22
+ return undefined;
23
+ try {
24
+ const url = new URL(baseUrl);
25
+ const host = url.hostname;
26
+ if (!host)
27
+ return undefined;
28
+ return [url.port ? `${host}:${url.port}` : host];
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ let capability = null;
35
+ export function getExecSandboxCapability() {
36
+ return capability;
37
+ }
38
+ /**
39
+ * 用回合真实会发的 egress 参数问 sidecar:这台机器的后端到底能达到哪一档
40
+ * 强制。boot 时问一次即可——后端选择只随平台与 egress 变化,两者在一次进程
41
+ * 生命周期内都是定的。
42
+ *
43
+ * 探测失败按「未知能力」处理(保持 null),`buildExecSandbox` 于是不要求
44
+ * full。宁可少收紧一档,也不能因为一次 RPC 抖动把整个 shell 面判死。
45
+ */
46
+ export async function probeExecSandboxCapability(supervisor) {
47
+ const endpoint = getActiveEgressProxyEndpoint();
48
+ try {
49
+ capability = await supervisor.call('sandbox.describe', {
50
+ network: true,
51
+ allowedHosts: endpoint
52
+ ? [endpoint]
53
+ : deriveEgressAllowListFromBaseUrl(llmService.getSettings().baseUrl),
54
+ });
55
+ log.info(`[sidecar] exec sandbox: ${capability.backend} → ${capability.enforcement}`);
56
+ }
57
+ catch (err) {
58
+ capability = null;
59
+ log.warn('[sidecar] exec sandbox probe failed; not requiring full enforcement', err);
60
+ }
61
+ }
62
+ /**
63
+ * 一个回合的 `execSandbox` 参数。
64
+ *
65
+ * `requireFull` 只在探测确认这台机器真能达到 full 时才开。它会在执行前拒掉
66
+ * 所有达不到 full 的调用,而带 egress 时只有 Seatbelt 能报 full(它按主机
67
+ * 钉死)——bwrap 与 Landlock 没有按主机管控、Windows 没有改写后端。从平台
68
+ * 猜测推导过一次,结果是代理一开、Linux 上每一次 shell 调用都被拒。
69
+ *
70
+ * `requireBackend` 在沙箱开启时始终开:请求了收容就不能因为没有后端而裸跑。
71
+ * `policy: 'full'` 关闭这一层(enabled/requireBackend 都为 false)。
72
+ */
73
+ export function buildExecSandbox(writableRoots, options) {
74
+ const policy = options?.policy ?? 'workspace';
75
+ const enabled = policy !== 'full' && process.env.STEERABLE_EXEC_SANDBOX !== '0';
76
+ const endpoint = getActiveEgressProxyEndpoint();
77
+ return {
78
+ enabled,
79
+ writableRoots: enabled ? writableRoots : [],
80
+ network: true,
81
+ // P3.2 shell-via-proxy:代理活跃时把 shell 出网收敛到本机代理端点
82
+ // (HTTP(S) 走代理的 CONNECT 名单;SSH 这类非 HTTP 出网由内核 fail-closed
83
+ // 拒掉)。代理未启用时退回 baseUrl 派生的远端条目。
84
+ allowedHosts: endpoint
85
+ ? [endpoint]
86
+ : deriveEgressAllowListFromBaseUrl(llmService.getSettings().baseUrl),
87
+ requireFull: enabled && capability?.enforcement === 'full',
88
+ requireBackend: enabled,
89
+ // W4.1.1:Windows 没有命令改写器——把 shell 调用路由到宿主的受限
90
+ // spawn(win-spawn-helper:受限令牌 + Job Object),而不是以
91
+ // enforcement:"none" 裸跑。其他平台无害:解析到本地后端就不会走这条。
92
+ hostSpawn: enabled && process.platform === 'win32',
93
+ };
94
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Optional sidecar handle. `main.ts` calls `setSidecarSupervisor()` after the
3
+ * sidecar has booted. The sidecar path is default-on (2026-08-26): the
4
+ * CoreLoop chat path and the LLM provider both route through it. Set
5
+ * `STEERABLE_USE_SIDECAR=0` to fall back to the in-process providers — also
6
+ * the automatic behavior when the sidecar failed to start.
7
+ *
8
+ * Kept in a dependency-light module (type-only import of the supervisor) so
9
+ * RPC thin clients (`local-edit`, `skill-loader`) can read the handle without
10
+ * pulling the LLM-service → storage → Electron graph into pure-Node test
11
+ * contexts.
12
+ */
13
+ import type { SidecarSupervisor } from './index.js';
14
+ export declare function setSidecarSupervisor(supervisor: SidecarSupervisor | null): void;
15
+ /**
16
+ * Register the in-flight boot promise so early callers (e.g. the renderer's
17
+ * skills request racing sidecar boot) can await readiness instead of
18
+ * observing a bare `null` and degrading. `main.ts` registers this
19
+ * synchronously inside `maybeStartSidecar()`, before the first await.
20
+ */
21
+ export declare function setSidecarSupervisorPending(pending: Promise<SidecarSupervisor | null> | null): void;
22
+ /** The supervised sidecar handle, when the sidecar path is active. */
23
+ export declare function getSidecarSupervisor(): SidecarSupervisor | null;
24
+ export declare function isSidecarEnabled(): boolean;
25
+ /**
26
+ * Await the sidecar handle through boot: returns the live handle immediately
27
+ * when already up, otherwise waits on the registered boot promise. Returns
28
+ * null when the sidecar is disabled, no boot is in flight, boot failed, or
29
+ * the wait exceeds `timeoutMs` (bounded so a hung python env degrades the
30
+ * caller to its no-sidecar fallback instead of hanging the request).
31
+ */
32
+ export declare function whenSidecarSupervisor(timeoutMs?: number): Promise<SidecarSupervisor | null>;