@steerable/agent-shell 0.6.72 → 0.6.73

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.
@@ -32,7 +32,7 @@ import { createAskUserBridge } from '../sidecar/reverse-ask-user.js';
32
32
  import { startHostSidecar, shutdownHostSidecar } from '../sidecar/boot.js';
33
33
  import { createVisibleTerminalExec } from './visible-terminal-exec.js';
34
34
  import { createJsonStore } from '../json-store.js';
35
- import { bindWorkspaceSkillRoots } from '../local-backend/skill-loader.js';
35
+ import { bindWorkspaceSkillRoots, listSkillRoots } from '../local-backend/skill-loader.js';
36
36
  import { collectPackExecWritableRoots } from '../local-backend/pack-turn-hooks.js';
37
37
  import { getChatAttachmentsDir } from '../attachments.js';
38
38
  import { ensureChatWorkspace } from '../project-home.js';
@@ -41,6 +41,20 @@ import { llmService } from '../llm/index.js';
41
41
  import { isProductLlmLocked, resolveRuntimeLlmSettings } from '../host-tools-runtime.js';
42
42
  import { getPackAssemblies, } from './pack-assembly.js';
43
43
  import { registerPackHttpRoutes } from './http-routes.js';
44
+ /**
45
+ * spill 目录作为只读根:工具输出超过内联上限时 sidecar 会把全文落到
46
+ * ``STEERABLE_SPILL_DIR``(BS 宿主由 run-bs-server.mjs 设为
47
+ * ``<userDataDir>/sidecar/steerable-spill``),并告诉模型去哪读全文。
48
+ * 只把它加进 shell 沙箱而不加进文件工具读根,模型就会「读自己刚被指到的
49
+ * 文件」而报路径越界——实测事故里正是如此。只读,不授予写入。
50
+ *
51
+ * 未设置该 env 时返回空表:sidecar 的回退目录是系统临时目录,把它整片
52
+ * 开给文件工具得不偿失,那种部署继续走 shell cat。
53
+ */
54
+ function spillReadRoots() {
55
+ const dir = process.env.STEERABLE_SPILL_DIR?.trim();
56
+ return dir ? [dir] : [];
57
+ }
44
58
  export async function createHostRuntime(options) {
45
59
  const { broadcast, hasWindow, onLog } = options;
46
60
  const broadcastMain = options.broadcastMain ?? broadcast;
@@ -242,14 +256,24 @@ export async function createHostRuntime(options) {
242
256
  store: defaultStore,
243
257
  toolRouter,
244
258
  resolveProjectRoot: async (chatId) => localBackendRouter.resolveChatWorkspaceRoot(chatId),
245
- // 写:源文件夹、包声明的可写根,以及各自的子目录。附件目录保持只读。
259
+ // 读:附件目录、源文件夹、包声明的可写根,以及只读参考面(技能根 /
260
+ // spill 根)。凡运行时"会写进去、模型需要读回来"的目录都必须可读;
261
+ // 否则 local_read_file 一律「路径越界」,模型只能退回 shell cat 绕行。
262
+ // 读集合不越出模型本就能读/本就被允许写入的范围。
246
263
  resolveAdditionalReadRoots: async (chatId) => {
247
264
  const project = await localBackendRouter.resolveChatProject(chatId);
248
265
  return [
249
266
  getChatAttachmentsDir(chatId),
250
267
  ...(project?.sourceFolders ?? []),
268
+ ...collectPackExecWritableRoots(chatId),
269
+ // 技能根:workflows/references/templates 是只读参考,模型必须能读
270
+ // (spec_lock_reference.md 定义 pptx_layouts 怎么写,正是它被拒过)。
271
+ ...listSkillRoots(),
272
+ // spill 根:工具输出过大时 sidecar 落到这里,模型要读回全文。
273
+ ...spillReadRoots(),
251
274
  ];
252
275
  },
276
+ // 写:源文件夹、包声明的可写根,以及各自的子目录。附件目录保持只读。
253
277
  resolveAdditionalWriteRoots: async (chatId) => {
254
278
  const project = await localBackendRouter.resolveChatProject(chatId);
255
279
  return [
@@ -11,7 +11,8 @@
11
11
  * 三个互相独立的执行点,任何一处漂移都会让「限制」变成假象:
12
12
  *
13
13
  * 1. 系统提示词注入 —— prompt-builder 的 `excludeSkillNames` / `pinnedSkillNames`
14
- * 2. sidecar 技能目录 —— `SkillTurnContext.exclude`
14
+ * 2. sidecar 技能面 —— `SkillTurnContext.exclude`(硬排除)+
15
+ * `SkillTurnContext.pinned`(目录去重但仍可按需加载)
15
16
  * 3. 工具面 —— 每轮 `turnTools`、`tool_search` 结果、反向通道分发复检
16
17
  *
17
18
  * 多个智能体配置按「最宽松」合并(父代理自身、或显式传入的列表)。
@@ -11,7 +11,8 @@
11
11
  * 三个互相独立的执行点,任何一处漂移都会让「限制」变成假象:
12
12
  *
13
13
  * 1. 系统提示词注入 —— prompt-builder 的 `excludeSkillNames` / `pinnedSkillNames`
14
- * 2. sidecar 技能目录 —— `SkillTurnContext.exclude`
14
+ * 2. sidecar 技能面 —— `SkillTurnContext.exclude`(硬排除)+
15
+ * `SkillTurnContext.pinned`(目录去重但仍可按需加载)
15
16
  * 3. 工具面 —— 每轮 `turnTools`、`tool_search` 结果、反向通道分发复检
16
17
  *
17
18
  * 多个智能体配置按「最宽松」合并(父代理自身、或显式传入的列表)。
@@ -57,11 +57,17 @@ export interface SkillTurnContext {
57
57
  /** 活跃条件串(tool:* / has-tools / plan-mode),与提示词拼装同源。 */
58
58
  conditions: string[];
59
59
  /**
60
- * 目录里不出现的技能:模式级排除(plan 模式排掉执行类技能)、智能体关闭
61
- * 「允许其他技能」时的未勾选项、以及已随系统提示词常驻的勾选项(避免
62
- * 模型再调 `skill` 工具加载第二遍)。
60
+ * 硬排除:模式级排除(plan 模式排掉执行类技能)、智能体关闭「允许其他
61
+ * 技能」时的未勾选项。sidecar 的 `skill` 工具会直接用
62
+ * `not available in this mode` 拒绝这些名字。
63
63
  */
64
64
  exclude: string[];
65
+ /**
66
+ * 已由宿主 eager 注入正文的 pinned 技能。sidecar 不应再把它们列进
67
+ * catalog(避免重复),但 `skill` 工具仍必须能按需加载完整正文——
68
+ * 大技能可能被 system prompt cap 截断,模型重载是合法恢复路径。
69
+ */
70
+ pinned: string[];
65
71
  /** 智能体的「加载全部技能」:catalog 无视条件全量列出。 */
66
72
  ignoreConditions: boolean;
67
73
  }
@@ -133,6 +133,11 @@ export declare class LocalBackendRouter {
133
133
  * `Documents/<应用名>/conversations/<chatId>/` 建对话工作区。
134
134
  */
135
135
  resolveChatWorkspaceRoot(chatId: string): Promise<string>;
136
+ /**
137
+ * 回合产物下载。只允许本会话 `turnFiles` 元数据里列出的文件,或落在
138
+ * 本会话工作区内的文件;验证通过后按 base64 返回,前端落 Blob 下载。
139
+ */
140
+ private handleLocalFileDownload;
136
141
  /**
137
142
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
138
143
  *
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
+ import { readFile, realpath, stat } from 'node:fs/promises';
4
5
  import { AsyncLocalStorage } from 'node:async_hooks';
5
6
  import os from 'node:os';
6
7
  import { getAppRootDir, getUserDataDir, shellOpenPath } from '../runtime.js';
@@ -45,7 +46,7 @@ import { handlePortableRequest } from './portable-service.js';
45
46
  import { isPortableProduct } from '../product-config.js';
46
47
  import { dropCurrentUserMessage } from './history-helper.js';
47
48
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
48
- import { chatAttachmentsDirPath } from '../attachments.js';
49
+ import { chatAttachmentsDirPath, isValidChatAttachmentsKey } from '../attachments.js';
49
50
  import { loadProjectRuleFiles } from '../project-rules.js';
50
51
  import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
51
52
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
@@ -84,6 +85,71 @@ function readProjectOrderIds(value) {
84
85
  }
85
86
  return ids;
86
87
  }
88
+ /** 回合产物下载上限:base64 走 localBackend JSON 通道,过大文件先拒绝。 */
89
+ const MAX_LOCAL_DOWNLOAD_BYTES = 128 * 1024 * 1024;
90
+ /** `target` 是否位于 `root` 内部(含 root 本身)。 */
91
+ function isPathInsideRoot(root, target) {
92
+ const rel = path.relative(path.resolve(root), path.resolve(target));
93
+ return rel === '' || (rel !== '..' &&
94
+ !rel.startsWith(`..${path.sep}`) &&
95
+ !path.isAbsolute(rel));
96
+ }
97
+ /** 从一条消息的 `messageMetadata` 里取出本回合产物的绝对路径。 */
98
+ function turnFilePathsFromMetadata(raw) {
99
+ if (typeof raw !== 'string' || !raw)
100
+ return [];
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
109
+ return [];
110
+ const files = parsed.turnFiles;
111
+ if (!Array.isArray(files))
112
+ return [];
113
+ const paths = [];
114
+ for (const item of files) {
115
+ if (!item || typeof item !== 'object')
116
+ continue;
117
+ const filePath = item.path;
118
+ if (typeof filePath === 'string' && filePath)
119
+ paths.push(filePath);
120
+ }
121
+ return paths;
122
+ }
123
+ const DOWNLOAD_MIME_BY_EXT = {
124
+ '.doc': 'application/msword',
125
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
126
+ '.xls': 'application/vnd.ms-excel',
127
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
128
+ '.ppt': 'application/vnd.ms-powerpoint', // shell-neutral:allow — Office 扩展名 / MIME,不是产品品牌
129
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // shell-neutral:allow — Office 扩展名 / MIME,不是产品品牌
130
+ '.pdf': 'application/pdf',
131
+ '.csv': 'text/csv',
132
+ '.txt': 'text/plain',
133
+ '.md': 'text/markdown',
134
+ '.json': 'application/json',
135
+ '.html': 'text/html',
136
+ '.htm': 'text/html',
137
+ '.png': 'image/png',
138
+ '.jpg': 'image/jpeg',
139
+ '.jpeg': 'image/jpeg',
140
+ '.gif': 'image/gif',
141
+ '.webp': 'image/webp',
142
+ '.svg': 'image/svg+xml',
143
+ '.mp3': 'audio/mpeg',
144
+ '.mp4': 'video/mp4',
145
+ '.zip': 'application/zip',
146
+ '.7z': 'application/x-7z-compressed',
147
+ '.tar': 'application/x-tar',
148
+ '.gz': 'application/gzip',
149
+ };
150
+ function downloadMimeForPath(filePath) {
151
+ return DOWNLOAD_MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
152
+ }
87
153
  /**
88
154
  * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
89
155
  * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
@@ -126,6 +192,19 @@ function coreLoopBudgetTokensFromEnv() {
126
192
  const n = Number(raw);
127
193
  return Number.isFinite(n) && n > 0 ? Math.trunc(n) : undefined;
128
194
  }
195
+ /**
196
+ * ``trace.fetch`` 的单次 RPC 超时。原来硬编码 5s:长跑的 trace 动辄几十 MB
197
+ * (实测 259 轮 → 161111 条事件 ≈ 39 MB),5s 必然超时,整条 trace 被静默丢弃
198
+ * ——偏偏最需要排查的长跑一条现场都不留。侧车侧已改成分片返回
199
+ * (``_handle_trace_fetch``,默认 ≤5 万条事件),这里再给足传输余量。
200
+ */
201
+ function traceFetchTimeoutMs() {
202
+ const raw = process.env.DEEPPATH_TRACE_FETCH_TIMEOUT_MS;
203
+ if (!raw)
204
+ return 60_000;
205
+ const n = Number(raw);
206
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 60_000;
207
+ }
129
208
  const LOCAL_USER_BASE = {
130
209
  id: 'local',
131
210
  email: 'local@localhost',
@@ -1617,13 +1696,6 @@ export class LocalBackendRouter {
1617
1696
  // Bearer、Anthropic x-api-key、Google x-goog-api-key),再与 models.dev
1618
1697
  // 能力表 join。refresh=1 绕过 60s 缓存。凭证随设置显式下发。
1619
1698
  if (method === 'GET' && pathname === '/api/v2/llm/models') {
1620
- const supervisor = await whenSidecarSupervisor();
1621
- if (!supervisor) {
1622
- return {
1623
- status: 503,
1624
- data: { error: 'sidecar 未就绪', models: [], catalogStatus: 'offline' },
1625
- };
1626
- }
1627
1699
  const settings = llmService.getSettings();
1628
1700
  const draftBase = url.searchParams.get('baseUrl');
1629
1701
  const draftKey = url.searchParams.get('apiKey');
@@ -1638,7 +1710,16 @@ export class LocalBackendRouter {
1638
1710
  }
1639
1711
  // 设置页验的是用户当场填的地址,通常还没保存,所以不在 boot 时派生的
1640
1712
  // 代理白名单上。放行它,否则每次「测试」都被自己的代理 403。
1713
+ // plain-http(本机 Ollama)放不进 CONNECT 代理,这里会重启 sidecar;
1714
+ // 必须在重启之后再取 supervisor,否则 listModels 打到已退出的进程。
1641
1715
  await allowEgressForBaseUrl(baseUrl);
1716
+ const supervisor = await whenSidecarSupervisor();
1717
+ if (!supervisor) {
1718
+ return {
1719
+ status: 503,
1720
+ data: { error: 'sidecar 未就绪', models: [], catalogStatus: 'offline' },
1721
+ };
1722
+ }
1642
1723
  const catalog = await supervisor.listModels({
1643
1724
  baseUrl,
1644
1725
  apiKey,
@@ -2018,6 +2099,12 @@ export class LocalBackendRouter {
2018
2099
  }
2019
2100
  return { status: 200, data: { success: true } };
2020
2101
  }
2102
+ // 回合产物「下载」:浏览器模式(团队版 / BS)没有系统应用可打开,
2103
+ // 由宿主读回文件字节,前端落成 Blob 下载。路径必须来自本会话的回合
2104
+ // 产物元数据或会话工作区,避免变成任意文件读取。
2105
+ if (method === 'GET' && pathname === '/api/v2/local/download') {
2106
+ return this.handleLocalFileDownload(url.searchParams.get('chatId') ?? '', url.searchParams.get('path') ?? '');
2107
+ }
2021
2108
  // 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
2022
2109
  // 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
2023
2110
  // 路径按对话绑定的项目根解析,无项目时按对话工作区。
@@ -2590,6 +2677,100 @@ export class LocalBackendRouter {
2590
2677
  return project.folderPath;
2591
2678
  return ensureChatWorkspace(chatId);
2592
2679
  }
2680
+ /**
2681
+ * 回合产物下载。只允许本会话 `turnFiles` 元数据里列出的文件,或落在
2682
+ * 本会话工作区内的文件;验证通过后按 base64 返回,前端落 Blob 下载。
2683
+ */
2684
+ async handleLocalFileDownload(chatIdRaw, targetRaw) {
2685
+ if (!getResolvedHostTools()['local-fs'].capability) {
2686
+ return { status: 403, data: { detail: '本地文件能力已关闭' } };
2687
+ }
2688
+ const chatId = chatIdRaw.trim();
2689
+ if (!chatId || !isValidChatAttachmentsKey(chatId)) {
2690
+ return { status: 400, data: { detail: 'chatId 无效' } };
2691
+ }
2692
+ const chat = await this.store.getChat(chatId);
2693
+ if (!chat) {
2694
+ return { status: 404, data: { detail: 'chat not found' } };
2695
+ }
2696
+ const target = targetRaw.trim();
2697
+ if (!target || target.includes('\0') || !path.isAbsolute(target)) {
2698
+ return { status: 400, data: { detail: '需要文件的绝对路径' } };
2699
+ }
2700
+ let realPath;
2701
+ let fileStat;
2702
+ try {
2703
+ realPath = await realpath(target);
2704
+ fileStat = await stat(realPath);
2705
+ }
2706
+ catch {
2707
+ return { status: 404, data: { detail: '找不到这个文件' } };
2708
+ }
2709
+ if (!fileStat.isFile()) {
2710
+ return { status: 404, data: { detail: '找不到这个文件' } };
2711
+ }
2712
+ if (fileStat.size > MAX_LOCAL_DOWNLOAD_BYTES) {
2713
+ return {
2714
+ status: 413,
2715
+ data: {
2716
+ detail: `文件太大,无法直接下载(上限 ${Math.round(MAX_LOCAL_DOWNLOAD_BYTES / 1024 / 1024)}MB)`,
2717
+ },
2718
+ };
2719
+ }
2720
+ const wanted = new Set([path.resolve(target), path.resolve(realPath)]);
2721
+ let allowed = false;
2722
+ const teamUserRoot = process.env.DEEPPATH_TEAM_USER_DATA_ROOT?.trim();
2723
+ if (teamUserRoot) {
2724
+ // 团队版是同一台机器上的多租户宿主:下载根必须落在当前用户数据目录
2725
+ // 或绑定项目内。只信任 turnFiles 元数据不够——符号链接可以指向别的
2726
+ // 用户目录,realpath 必须在这些根里才放行。
2727
+ const roots = [getUserDataDir()];
2728
+ try {
2729
+ const project = await this.resolveChatProject(chatId);
2730
+ if (project)
2731
+ roots.push(project.folderPath, ...project.sourceFolders);
2732
+ }
2733
+ catch {
2734
+ // 项目注册表未就绪时只认用户数据目录。
2735
+ }
2736
+ allowed = roots.some((root) => isPathInsideRoot(root, realPath));
2737
+ }
2738
+ else {
2739
+ const messages = await this.store.listMessages(chatId, 1000);
2740
+ for (const message of messages) {
2741
+ for (const listed of turnFilePathsFromMetadata(message.messageMetadata)) {
2742
+ if (wanted.has(path.resolve(listed))) {
2743
+ allowed = true;
2744
+ break;
2745
+ }
2746
+ }
2747
+ if (allowed)
2748
+ break;
2749
+ }
2750
+ if (!allowed) {
2751
+ try {
2752
+ const workspaceRoot = await this.resolveChatWorkspaceRoot(chatId);
2753
+ allowed = isPathInsideRoot(workspaceRoot, realPath);
2754
+ }
2755
+ catch {
2756
+ allowed = false;
2757
+ }
2758
+ }
2759
+ }
2760
+ if (!allowed) {
2761
+ return { status: 404, data: { detail: '这个文件不属于当前对话' } };
2762
+ }
2763
+ const buffer = await readFile(realPath);
2764
+ return {
2765
+ status: 200,
2766
+ data: {
2767
+ fileName: path.basename(realPath),
2768
+ mime: downloadMimeForPath(realPath),
2769
+ size: buffer.length,
2770
+ base64: buffer.toString('base64'),
2771
+ },
2772
+ };
2773
+ }
2593
2774
  /**
2594
2775
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
2595
2776
  *
@@ -2765,15 +2946,15 @@ export class LocalBackendRouter {
2765
2946
  ...conditionsFromTools(toolNames),
2766
2947
  ...(chatMode === 'plan' ? ['plan-mode'] : []),
2767
2948
  ],
2768
- // 勾选的技能正文已随系统提示词常驻,目录里不再重复列出——否则模型
2769
- // 会再调一次 `skill` 工具把同一份正文加载第二遍。
2770
- exclude: [
2771
- ...new Set([
2772
- ...excludeSkillNames,
2773
- ...built.modules
2774
- .filter((m) => isSkillPinned(m, capability.pinnedSkills))
2775
- .map((m) => m.dirName || m.name),
2776
- ]),
2949
+ // 硬排除只放模式级/白名单排除。pinned 技能单独走 `pinned`:
2950
+ // sidecar 不把它列进 catalog(宿主已 eager 注入正文),但仍允许
2951
+ // `skill` 工具按需加载完整正文;否则大技能被 system prompt cap
2952
+ // 截断后,模型重载会被误报成“not available in this mode”。
2953
+ exclude: [...new Set(excludeSkillNames)],
2954
+ pinned: [
2955
+ ...new Set(built.modules
2956
+ .filter((m) => isSkillPinned(m, capability.pinnedSkills))
2957
+ .map((m) => m.dirName || m.name)),
2777
2958
  ],
2778
2959
  ignoreConditions: capability.loadAllSkills,
2779
2960
  };
@@ -3715,8 +3896,18 @@ export class LocalBackendRouter {
3715
3896
  // 只有最后一趟携带本轮终态;之前每一趟都是撞墙截停才有下一趟。
3716
3897
  const passStatus = index === passTraceIds.length - 1 ? completionStatus : 'budget_exhausted';
3717
3898
  try {
3718
- const fetched = await supervisor.call('trace.fetch', { traceId }, { timeoutMs: 5_000 });
3899
+ const fetched = await supervisor.call('trace.fetch', { traceId }, { timeoutMs: traceFetchTimeoutMs() });
3719
3900
  const sidecarTrace = fetched.trace ?? {};
3901
+ if (fetched.eventsTruncated) {
3902
+ // 侧车把超长 trace 截成 头+尾(见 _handle_trace_fetch)。留痕,
3903
+ // 否则被截断的 trace 会被当成完整现场读。
3904
+ console.warn('[local-backend] coreloop trace truncated for storage', {
3905
+ chatId,
3906
+ traceId,
3907
+ eventsReturned: (fetched.events ?? []).length,
3908
+ eventsTotal: fetched.eventsTotal,
3909
+ });
3910
+ }
3720
3911
  const durationMs = typeof sidecarTrace.durationMs === 'number'
3721
3912
  ? sidecarTrace.durationMs
3722
3913
  : null;
@@ -3732,11 +3923,27 @@ export class LocalBackendRouter {
3732
3923
  trace: sidecarTrace,
3733
3924
  spans: fetched.spans ?? [],
3734
3925
  events: fetched.events ?? [],
3926
+ ...(fetched.eventsTruncated
3927
+ ? {
3928
+ eventsTruncated: true,
3929
+ eventsTotal: fetched.eventsTotal,
3930
+ }
3931
+ : {}),
3735
3932
  },
3736
3933
  });
3737
3934
  }
3738
3935
  catch (err) {
3739
- console.warn('[local-backend] coreloop trace persist failed', err);
3936
+ // 丢了 trace 就没有现场:这条日志必须带够可定位信息,且措辞直白
3937
+ // 到能直接 grep(历史事故里 259 轮/token 量最大的三个 run 全因此
3938
+ // 没有任何 trace 可查)。
3939
+ console.warn('[local-backend] coreloop trace persist failed', {
3940
+ chatId,
3941
+ traceId,
3942
+ passStatus,
3943
+ pass: index + 1,
3944
+ of: passTraceIds.length,
3945
+ err: err instanceof Error ? err.message : String(err),
3946
+ });
3740
3947
  }
3741
3948
  // W6-6 遥测合规化:若用户配置了 OTLP collector,把这条 trace 按所选
3742
3949
  // 隐私档位导出(默认 metadata——只出结构/时延/状态,不出内容)。
@@ -16,7 +16,7 @@ import { deriveEgressAllowListFromBaseUrl, probeExecSandboxCapability, } from '.
16
16
  import { createToolInvokeHandler } from './reverse-tools.js';
17
17
  import { createProcessSpawnHandler } from './reverse-spawn.js';
18
18
  import { collectAmbientProxyEndpoints } from './proxy-detect.js';
19
- import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, deriveWebEgressHosts, pickFreePort, recordEgressPosture, startEgressProxy, } from './egress-proxy.js';
19
+ import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, deriveWebEgressHosts, pickFreePort, recordEgressPosture, seatbeltHostEntry, seatbeltProfileCoversBaseUrl, setPlainEndpointRestarter, startEgressProxy, } from './egress-proxy.js';
20
20
  import { WEB_TOOL_RPC_TIMEOUT_MS, webToolsEnabled } from '../tool-router.js';
21
21
  import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
22
22
  import { executeHostedWebSearch } from '../hosted-web-search.js';
@@ -24,6 +24,67 @@ import { getProductConfig } from '../product-config.js';
24
24
  let egressProxyRef = null;
25
25
  let supervisorRef = null;
26
26
  let sidecarGeneration = 0;
27
+ let hostSidecarDeps = null;
28
+ /** plain-http 端点。spawn 名单里没有它时,重启前写在这里,下一次 profile 带上。 */
29
+ let admittedPlainBaseUrl;
30
+ let spawnEgress = null;
31
+ let restartInFlight = null;
32
+ let restarting = false;
33
+ function withAdmittedPlain(hosts) {
34
+ if (!hosts)
35
+ return hosts;
36
+ const extra = seatbeltHostEntry(admittedPlainBaseUrl);
37
+ if (!extra || hosts.includes(extra))
38
+ return hosts;
39
+ return [...hosts, extra];
40
+ }
41
+ async function restartHostSidecar() {
42
+ const deps = hostSidecarDeps;
43
+ if (!deps)
44
+ return;
45
+ if (restartInFlight) {
46
+ await restartInFlight;
47
+ return;
48
+ }
49
+ const run = (async () => {
50
+ restarting = true;
51
+ try {
52
+ await shutdownHostSidecar();
53
+ await startHostSidecar(deps);
54
+ }
55
+ finally {
56
+ restarting = false;
57
+ }
58
+ })();
59
+ restartInFlight = run;
60
+ try {
61
+ await run;
62
+ }
63
+ finally {
64
+ if (restartInFlight === run)
65
+ restartInFlight = null;
66
+ }
67
+ }
68
+ setPlainEndpointRestarter(async (baseUrl) => {
69
+ if (!hostSidecarDeps || !spawnEgress)
70
+ return false;
71
+ if (seatbeltProfileCoversBaseUrl(baseUrl, spawnEgress.hosts, spawnEgress.webEgress)) {
72
+ return false;
73
+ }
74
+ const extra = seatbeltHostEntry(baseUrl);
75
+ if (!extra)
76
+ return false;
77
+ admittedPlainBaseUrl = baseUrl;
78
+ log.info(`[sidecar] restarting to admit ${extra}`);
79
+ try {
80
+ await restartHostSidecar();
81
+ }
82
+ catch (err) {
83
+ log.warn('[sidecar] restart for plain endpoint failed', err);
84
+ return false;
85
+ }
86
+ return seatbeltProfileCoversBaseUrl(baseUrl, spawnEgress?.hosts, spawnEgress?.webEgress ?? false);
87
+ });
27
88
  async function deriveSidecarEgressAllowList() {
28
89
  const envList = (process.env.STEERABLE_SIDECAR_SANDBOX_ALLOWED_HOSTS ?? '')
29
90
  .split(',')
@@ -31,7 +92,7 @@ async function deriveSidecarEgressAllowList() {
31
92
  .filter(Boolean);
32
93
  if (envList.length > 0)
33
94
  return envList;
34
- const derived = deriveEgressAllowListFromBaseUrl(llmService.getSettings().baseUrl);
95
+ const derived = deriveEgressAllowListFromBaseUrl(llmService.getSettings().baseUrl) ?? [];
35
96
  // W4-8: the sidecar's httpx stack honors ambient proxies (env vars, and
36
97
  // the macOS system proxy via getproxies). A configured proxy is an
37
98
  // effective egress point — without it in the allow-list, the sandbox
@@ -39,7 +100,7 @@ async function deriveSidecarEgressAllowList() {
39
100
  // system proxy 127.0.0.1:7890 hijacked even 127.0.0.1:11434 because
40
101
  // getproxies() does not surface the system bypass list).
41
102
  const proxies = await collectAmbientProxyEndpoints();
42
- const merged = [...new Set([...(derived ?? []), ...proxies])];
103
+ const merged = withAdmittedPlain([...new Set([...derived, ...proxies])]) ?? [];
43
104
  if (merged.length === 0) {
44
105
  log.warn('[sidecar] cannot derive egress allow-list from provider baseUrl; outbound stays open');
45
106
  return undefined;
@@ -196,6 +257,7 @@ export async function wirePluginRegistry(supervisor, toolRouter) {
196
257
  * et al.) can await it via whenSidecarSupervisor().
197
258
  */
198
259
  export async function startHostSidecar(deps) {
260
+ hostSidecarDeps = deps;
199
261
  const generation = ++sidecarGeneration;
200
262
  const pythonRunner = process.env.STEERABLE_PYTHON?.trim();
201
263
  const runCodeEnabled = true;
@@ -305,6 +367,14 @@ export async function startHostSidecar(deps) {
305
367
  // profile already allows writes to; userData would be denied.
306
368
  // Path is per-flavor / env-overridable — see resolveSidecarStoragePath.
307
369
  const storagePath = resolveSidecarStoragePath();
370
+ // W4-3: sandbox default-on. This list is fixed at spawn. HTTPS hosts
371
+ // are added at runtime via allowEgressForBaseUrl. plain-http (Ollama)
372
+ // cannot, so that path restarts the sidecar with the new host appended.
373
+ const sandboxAllowedHosts = withAdmittedPlain(egressProxy?.sandboxAllowedHosts ?? (await deriveSidecarEgressAllowList()));
374
+ // egress-proxy 模式下不开 web-egress(*:80/443 会架空代理的按主机
375
+ // 名单):web 工具改走代理(HTTPS_PROXY),代理白名单与 sidecar
376
+ // 应用层域名名单同源(3.1b/3.1d)。
377
+ const sandboxWebEgress = webToolsEnabled() && !egressProxy;
308
378
  const supervisor = await SidecarSupervisor.start({
309
379
  args: [
310
380
  '--storage-path',
@@ -314,16 +384,8 @@ export async function startHostSidecar(deps) {
314
384
  // 测试 tmpdir、显式 STEERABLE_SIDECAR_STORAGE_PATH)时,沙箱必须显式
315
385
  // 授予该目录写权限,否则 sidecar 打不开 sessions.db/sessions.lock。
316
386
  sandboxWritableRoots: [path.dirname(storagePath)],
317
- // W4-3: sandbox default-on. This list is fixed at spawn (the sandbox
318
- // profile cannot be edited afterwards), so a provider change reaches it
319
- // only on restart — plain-http endpoints therefore still need one. The
320
- // proxy's own CONNECT list does track the setting at runtime, via
321
- // allowEgressForBaseUrl.
322
- sandboxAllowedHosts: egressProxy?.sandboxAllowedHosts ?? (await deriveSidecarEgressAllowList()),
323
- // egress-proxy 模式下不开 web-egress(*:80/443 会架空代理的按主机
324
- // 名单):web 工具改走代理(HTTPS_PROXY),代理白名单与 sidecar
325
- // 应用层域名名单同源(3.1b/3.1d)。
326
- sandboxWebEgress: webToolsEnabled() && !egressProxy,
387
+ sandboxAllowedHosts,
388
+ sandboxWebEgress,
327
389
  // 3.1b:代理模式下 web_fetch 的 SSRF 预检仍在沙箱内做 DNS(抓取
328
390
  // 本身走代理隧道),所以只放行解析器 socket——无 IP 可达性。
329
391
  sandboxAllowResolver: webToolsEnabled() && Boolean(egressProxy),
@@ -356,6 +418,7 @@ export async function startHostSidecar(deps) {
356
418
  void created.shutdown();
357
419
  }
358
420
  });
421
+ spawnEgress = { hosts: sandboxAllowedHosts, webEgress: sandboxWebEgress };
359
422
  await wireSupervisor(supervisor);
360
423
  return supervisor;
361
424
  }
@@ -375,6 +438,15 @@ export async function startHostSidecar(deps) {
375
438
  /** 宿主退出钩子:停 supervisor + egress proxy。幂等。 */
376
439
  export async function shutdownHostSidecar() {
377
440
  sidecarGeneration += 1;
441
+ // Drop the resolved boot promise before the process exits so waiters do
442
+ // not observe the supervisor being stopped. A restart keeps the admitted
443
+ // plain-http endpoint so the next spawn's profile includes it.
444
+ setSidecarSupervisorPending(null);
445
+ if (!restarting) {
446
+ admittedPlainBaseUrl = undefined;
447
+ spawnEgress = null;
448
+ hostSidecarDeps = null;
449
+ }
378
450
  if (supervisorRef) {
379
451
  const supervisor = supervisorRef;
380
452
  supervisorRef = null;
@@ -65,6 +65,22 @@ export declare function egressAllowEntry(baseUrl: string | undefined): {
65
65
  port: string;
66
66
  proxied: boolean;
67
67
  } | null;
68
+ /**
69
+ * `host` or `host:port` for `baseUrl`, or null when it cannot be written
70
+ * into a Seatbelt profile.
71
+ */
72
+ export declare function seatbeltHostEntry(baseUrl: string | undefined): string | null;
73
+ /**
74
+ * Whether a Seatbelt profile spawned with this allow-list can already open
75
+ * `baseUrl`.
76
+ *
77
+ * `allowedHosts === undefined` is the open-outbound profile. Loopback is not
78
+ * covered by `*:80` / `*:443`: Seatbelt's `*` does not match `localhost`, so
79
+ * a local runtime on `127.0.0.1:11434` needs its own `localhost:11434` rule.
80
+ * A remote host with no port degrades to 443 and 80 for every non-loopback
81
+ * target, matching the profile builder.
82
+ */
83
+ export declare function seatbeltProfileCoversBaseUrl(baseUrl: string | undefined, allowedHosts: readonly string[] | undefined, webEgress: boolean): boolean;
68
84
  /**
69
85
  * 从 provider baseUrl 构建代理计划。https 端点进代理名单;http 端点留在
70
86
  * 直连列表。派生不出任何代理条目(无 https 端点且无 web 域名)时返回
@@ -166,6 +182,8 @@ export declare function decideEgressProxy(options: {
166
182
  };
167
183
  export declare function getEgressPosture(): EgressPosture | null;
168
184
  export declare function getActiveEgressProxyEndpoint(): string | null;
185
+ /** boot 注册。plain-http 端点改不了已启动的 Seatbelt profile,只能重启 sidecar。 */
186
+ export declare function setPlainEndpointRestarter(restarter: ((baseUrl: string) => Promise<boolean>) | null): void;
169
187
  /**
170
188
  * 把 `baseUrl` 的主机加进运行中代理的会话白名单。
171
189
  *
@@ -181,9 +199,12 @@ export declare function getActiveEgressProxyEndpoint(): string | null;
181
199
  * 放行是会话域的(代理进程退出即失效),条目形态与 `buildEgressProxyPlan`
182
200
  * 的派生一致,两处语义不会分叉。
183
201
  *
184
- * @returns 已放行为 true;无可放行之处为 false——代理没在跑、URL 解析不了,
185
- * 或端点是 plain-http(那类端点不进代理,由 spawn 时的沙箱 profile 钉死,
186
- * 运行期改不了,仍需重启)。
202
+ * plain-http 不进 CONNECT 代理。Seatbelt profile 在 spawn 时钉死,这类端点
203
+ * 只能靠 boot 注册的 restarter 重启 sidecar,把主机写进新 profile。
204
+ *
205
+ * @param baseUrl 要放行的 provider 地址。
206
+ * @returns 已放行或已重启 sidecar 为 true。代理没在跑、URL 解析不了、端点
207
+ * 是 plain-http,且没有 restarter(或名单已经覆盖)时为 false。
187
208
  */
188
209
  export declare function allowEgressForBaseUrl(baseUrl: string | undefined): Promise<boolean>;
189
210
  export interface EgressProxyHandle {
@@ -51,6 +51,67 @@ export function egressAllowEntry(baseUrl) {
51
51
  proxied: url.protocol === 'https:',
52
52
  };
53
53
  }
54
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
55
+ const DEFAULT_REMOTE_PORTS = [443, 80];
56
+ /** Same alphabet as the Seatbelt profile builder. Anything else is not a host entry. */
57
+ const HOST_ENTRY_RE = /^[A-Za-z0-9._-]+(?::[0-9]+)?$/;
58
+ /**
59
+ * `host` or `host:port` for `baseUrl`, or null when it cannot be written
60
+ * into a Seatbelt profile.
61
+ */
62
+ export function seatbeltHostEntry(baseUrl) {
63
+ const target = egressAllowEntry(baseUrl);
64
+ if (!target || !HOST_ENTRY_RE.test(target.entry))
65
+ return null;
66
+ return target.entry;
67
+ }
68
+ function parseSeatbeltEntry(entry) {
69
+ const trimmed = entry.trim();
70
+ if (!HOST_ENTRY_RE.test(trimmed))
71
+ return null;
72
+ const colon = trimmed.lastIndexOf(':');
73
+ const portText = colon > 0 ? trimmed.slice(colon + 1) : '';
74
+ const hasPort = /^\d+$/.test(portText);
75
+ const host = hasPort ? trimmed.slice(0, colon) : trimmed;
76
+ const port = hasPort ? Number(portText) : null;
77
+ if (port !== null && (port < 1 || port > 65535))
78
+ return null;
79
+ return {
80
+ loopback: LOOPBACK_HOSTS.has(host),
81
+ ports: port === null ? DEFAULT_REMOTE_PORTS : [port],
82
+ };
83
+ }
84
+ /**
85
+ * Whether a Seatbelt profile spawned with this allow-list can already open
86
+ * `baseUrl`.
87
+ *
88
+ * `allowedHosts === undefined` is the open-outbound profile. Loopback is not
89
+ * covered by `*:80` / `*:443`: Seatbelt's `*` does not match `localhost`, so
90
+ * a local runtime on `127.0.0.1:11434` needs its own `localhost:11434` rule.
91
+ * A remote host with no port degrades to 443 and 80 for every non-loopback
92
+ * target, matching the profile builder.
93
+ */
94
+ export function seatbeltProfileCoversBaseUrl(baseUrl, allowedHosts, webEgress) {
95
+ if (allowedHosts === undefined)
96
+ return true;
97
+ const target = egressAllowEntry(baseUrl);
98
+ if (!target || !HOST_ENTRY_RE.test(target.entry))
99
+ return true;
100
+ const port = target.port ? Number(target.port) : target.proxied ? 443 : 80;
101
+ if (port < 1 || port > 65535)
102
+ return true;
103
+ const loopback = LOOPBACK_HOSTS.has(target.host);
104
+ if (!loopback && webEgress && (port === 80 || port === 443))
105
+ return true;
106
+ for (const entry of allowedHosts) {
107
+ const parsed = parseSeatbeltEntry(entry);
108
+ if (!parsed || !parsed.ports.includes(port))
109
+ continue;
110
+ if (loopback ? parsed.loopback : !parsed.loopback)
111
+ return true;
112
+ }
113
+ return false;
114
+ }
54
115
  /**
55
116
  * 从 provider baseUrl 构建代理计划。https 端点进代理名单;http 端点留在
56
117
  * 直连列表。派生不出任何代理条目(无 https 端点且无 web 域名)时返回
@@ -369,6 +430,16 @@ export function getActiveEgressProxyEndpoint() {
369
430
  * 主进程内存,与下发给 sidecar 的是同一个值。
370
431
  */
371
432
  let activeControl = null;
433
+ let plainEndpointRestarter = null;
434
+ /** boot 注册。plain-http 端点改不了已启动的 Seatbelt profile,只能重启 sidecar。 */
435
+ export function setPlainEndpointRestarter(restarter) {
436
+ plainEndpointRestarter = restarter;
437
+ }
438
+ async function restartForUnproxied(baseUrl) {
439
+ if (!baseUrl || !plainEndpointRestarter)
440
+ return false;
441
+ return plainEndpointRestarter(baseUrl);
442
+ }
372
443
  /**
373
444
  * 把 `baseUrl` 的主机加进运行中代理的会话白名单。
374
445
  *
@@ -384,17 +455,20 @@ let activeControl = null;
384
455
  * 放行是会话域的(代理进程退出即失效),条目形态与 `buildEgressProxyPlan`
385
456
  * 的派生一致,两处语义不会分叉。
386
457
  *
387
- * @returns 已放行为 true;无可放行之处为 false——代理没在跑、URL 解析不了,
388
- * 或端点是 plain-http(那类端点不进代理,由 spawn 时的沙箱 profile 钉死,
389
- * 运行期改不了,仍需重启)。
458
+ * plain-http 不进 CONNECT 代理。Seatbelt profile 在 spawn 时钉死,这类端点
459
+ * 只能靠 boot 注册的 restarter 重启 sidecar,把主机写进新 profile。
460
+ *
461
+ * @param baseUrl 要放行的 provider 地址。
462
+ * @returns 已放行或已重启 sidecar 为 true。代理没在跑、URL 解析不了、端点
463
+ * 是 plain-http,且没有 restarter(或名单已经覆盖)时为 false。
390
464
  */
391
465
  export async function allowEgressForBaseUrl(baseUrl) {
392
466
  const control = activeControl;
393
467
  if (!control)
394
- return false;
468
+ return restartForUnproxied(baseUrl);
395
469
  const target = egressAllowEntry(baseUrl);
396
470
  if (!target || !target.proxied)
397
- return false;
471
+ return restartForUnproxied(baseUrl);
398
472
  const entry = target.entry;
399
473
  try {
400
474
  const response = await fetch(`http://127.0.0.1:${control.port}/allow`, {
@@ -383,7 +383,17 @@ export interface SidecarChatStreamRequest {
383
383
  skills?: {
384
384
  roots: string[];
385
385
  conditions?: string[];
386
+ /**
387
+ * Hard mode/whitelist exclusions. Sidecar's skill tool refuses these
388
+ * names with `not available in this mode`.
389
+ */
386
390
  exclude?: string[];
391
+ /**
392
+ * Host-injected/eager skills. Suppressed from the sidecar catalog (the
393
+ * host already put the body in the system prompt) but still loadable by
394
+ * the skill tool, so truncation or a model re-load never dead-ends.
395
+ */
396
+ pinned?: string[];
387
397
  ignoreConditions?: boolean;
388
398
  mode?: 'layered' | 'eager';
389
399
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.72",
3
+ "version": "0.6.73",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -53,9 +53,9 @@
53
53
  "jpeg-js": "^0.4.4",
54
54
  "node-pty": "^1.1.0",
55
55
  "pngjs": "^7.0.0",
56
- "@steerable/agent-harness": "0.6.72",
57
- "@steerable/agent-protocol": "0.6.72",
58
- "@steerable/pack-sdk": "0.6.72"
56
+ "@steerable/agent-harness": "0.6.73",
57
+ "@steerable/agent-protocol": "0.6.73",
58
+ "@steerable/pack-sdk": "0.6.73"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/better-sqlite3": "^7.6.13",