@steerable/agent-shell 0.6.31 → 0.6.33

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.
@@ -28,8 +28,10 @@ import { createVisibleTerminalExec } from './visible-terminal-exec.js';
28
28
  import { createJsonStore } from '../json-store.js';
29
29
  import { bindWorkspaceSkillRoots } from '../local-backend/skill-loader.js';
30
30
  import { getChatAttachmentsDir } from '../attachments.js';
31
+ import { ensureChatWorkspace } from '../project-home.js';
31
32
  import { recordInsightTurn } from '../insights/record.js';
32
33
  import { llmService } from '../llm/index.js';
34
+ import { isProductLlmLocked, resolveRuntimeLlmSettings } from '../host-tools-runtime.js';
33
35
  import { getPackAssemblies, } from './pack-assembly.js';
34
36
  export async function createHostRuntime(options) {
35
37
  const { broadcast, hasWindow, onLog } = options;
@@ -64,15 +66,14 @@ export async function createHostRuntime(options) {
64
66
  // 生效,这里负责进程重启后的恢复)。
65
67
  {
66
68
  const persistedLlmSettings = await defaultStore.getLlmSettings();
67
- setDefaultExecTimeoutMs(persistedLlmSettings?.execTimeoutSeconds
68
- ? persistedLlmSettings.execTimeoutSeconds * 1000
69
+ const runtimeLlmSettings = resolveRuntimeLlmSettings(persistedLlmSettings);
70
+ if (isProductLlmLocked()) {
71
+ await defaultStore.setLlmSettings(runtimeLlmSettings);
72
+ }
73
+ setDefaultExecTimeoutMs(runtimeLlmSettings.execTimeoutSeconds
74
+ ? runtimeLlmSettings.execTimeoutSeconds * 1000
69
75
  : null);
70
- llmService.initialize(persistedLlmSettings ?? {
71
- provider: 'ollama',
72
- model: 'llama3.1:8b',
73
- baseUrl: 'http://127.0.0.1:11434',
74
- temperature: 0.3,
75
- }, {
76
+ llmService.initialize(runtimeLlmSettings, {
76
77
  setLlmSettings: (settings) => defaultStore.setLlmSettings(settings),
77
78
  });
78
79
  }
@@ -150,6 +151,12 @@ export async function createHostRuntime(options) {
150
151
  toolRouter,
151
152
  worktreeService,
152
153
  resolveChatProject: resolveChatProjectRoot,
154
+ resolveChatWorkspaceRoot: async (chatId) => {
155
+ const project = await resolveChatProjectRoot(chatId);
156
+ if (project)
157
+ return project.folderPath;
158
+ return ensureChatWorkspace(chatId);
159
+ },
153
160
  broadcast,
154
161
  });
155
162
  toolRouter.setTaskServices({ taskService, worktreeService });
@@ -204,7 +211,7 @@ export async function createHostRuntime(options) {
204
211
  void startHostSidecar({
205
212
  store: defaultStore,
206
213
  toolRouter,
207
- resolveProjectRoot: async (chatId) => (await localBackendRouter.resolveChatProject(chatId))?.folderPath ?? null,
214
+ resolveProjectRoot: async (chatId) => localBackendRouter.resolveChatWorkspaceRoot(chatId),
208
215
  // 项目模式下文件读写被围栏在项目目录内;会话附件目录是额外放行的
209
216
  // 只读根,保证用户上传的文件即使在项目会话里也能被 agent 读回。
210
217
  resolveAdditionalReadRoots: async (chatId) => {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import log from 'electron-log';
7
7
  import { GUI_LAUNCH_WAIT_MS, getConfiguredExecTimeoutMs, isGuiLaunchCommand, rewriteExeCommandIfNeeded, } from '../local-executor.js';
8
+ import { getResolvedHostTools } from '../host-tools-runtime.js';
8
9
  /**
9
10
  * Map a raw PTY shell binary name (e.g. `powershell.exe`, `cmd.exe`, `zsh`) to
10
11
  * the {@link LocalExecResult} `shell` enum so the agent sees the *real* dialect
@@ -33,6 +34,9 @@ export function mapSessionShell(shell) {
33
34
  export function createVisibleTerminalExec(deps) {
34
35
  const { localExecutor, terminalManager } = deps;
35
36
  return async function maybeExecInTerminal(req) {
37
+ if (!getResolvedHostTools().terminal.chrome) {
38
+ return null;
39
+ }
36
40
  if (req.command) {
37
41
  req.command = rewriteExeCommandIfNeeded(req.command);
38
42
  }
@@ -0,0 +1,10 @@
1
+ import { buildHostApproval, type ChatModeId, type ResolvedHostTools } from './host-tools.js';
2
+ import { type LlmSettings } from './storage/llm-settings.js';
3
+ export declare function getResolvedHostTools(): ResolvedHostTools;
4
+ export declare function isProductApprovalEnabled(): boolean;
5
+ export declare function resolveTurnApproval(storePath?: string): ReturnType<typeof buildHostApproval>;
6
+ export declare function assertHostIpcAllowed(channel: string): void;
7
+ export declare function resolveTurnChatMode(requested: unknown): ChatModeId;
8
+ export declare function isProductLlmLocked(): boolean;
9
+ /** 设置页开着走已存/出厂默认;关掉则钉死 product.json 的 llm。 */
10
+ export declare function resolveRuntimeLlmSettings(persisted: LlmSettings | null): LlmSettings;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 把产品注入的 hostTools / approval 接到解析器。
3
+ * host-tools.ts 保持无 node 依赖,供测试与渲染层复用解析规则。
4
+ */
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { getProductConfig } from './product-config.js';
8
+ import { buildHostApproval, clampChatMode, isHostIpcAllowed, resolveChatModes, resolveHostTools, resolveSettingsChrome, } from './host-tools.js';
9
+ import { DEFAULT_LLM_SETTINGS, resolveLockedLlmSettings, } from './storage/llm-settings.js';
10
+ export function getResolvedHostTools() {
11
+ return resolveHostTools(getProductConfig().hostTools);
12
+ }
13
+ export function isProductApprovalEnabled() {
14
+ return (buildHostApproval({
15
+ productApproval: getProductConfig().approval,
16
+ envApproval: process.env.STEERABLE_APPROVAL,
17
+ storePath: 'unused',
18
+ }) !== undefined);
19
+ }
20
+ export function resolveTurnApproval(storePath = path.join(os.homedir(), '.steerable', 'approvals.json')) {
21
+ return buildHostApproval({
22
+ productApproval: getProductConfig().approval,
23
+ envApproval: process.env.STEERABLE_APPROVAL,
24
+ storePath,
25
+ });
26
+ }
27
+ export function assertHostIpcAllowed(channel) {
28
+ if (!isHostIpcAllowed(channel, getResolvedHostTools())) {
29
+ throw new Error(`${channel} is disabled for this product`);
30
+ }
31
+ }
32
+ export function resolveTurnChatMode(requested) {
33
+ return clampChatMode(requested, resolveChatModes(getProductConfig().chatModes));
34
+ }
35
+ export function isProductLlmLocked() {
36
+ return !resolveSettingsChrome(getProductConfig().settings).llm;
37
+ }
38
+ /** 设置页开着走已存/出厂默认;关掉则钉死 product.json 的 llm。 */
39
+ export function resolveRuntimeLlmSettings(persisted) {
40
+ if (!isProductLlmLocked()) {
41
+ return persisted ?? DEFAULT_LLM_SETTINGS;
42
+ }
43
+ return resolveLockedLlmSettings(getProductConfig().llm, process.env, persisted);
44
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 宿主工具族:产品声明引入哪些工具,服务端按同一份配置钳死围栏 / 路由 / 工具面。
3
+ * 渲染层只投影 chrome;缺省(中性 shell、未声明的族)全开,兼容存量产品。
4
+ */
5
+ export declare const HOST_TOOL_FAMILY_IDS: readonly ["terminal", "local-fs", "projects", "background-tasks", "mcp", "web", "plugins"];
6
+ export type HostToolFamilyId = (typeof HOST_TOOL_FAMILY_IDS)[number];
7
+ export interface HostToolFamilySurface {
8
+ /** 模型可见工具 + 围栏 / 服务端能力。 */
9
+ capability: boolean;
10
+ /** 用户入口:按钮、菜单、项目分组、点路径打开、附件上传、可见 PTY。 */
11
+ chrome: boolean;
12
+ }
13
+ /** 产品声明:`true` 两边开,`false` 两边关,对象可拆 chrome / capability。 */
14
+ export type HostToolsConfig = Partial<Record<HostToolFamilyId, boolean | {
15
+ capability?: boolean;
16
+ chrome?: boolean;
17
+ }>>;
18
+ export type ResolvedHostTools = Record<HostToolFamilyId, HostToolFamilySurface>;
19
+ export type ApprovalMode = 'host' | 'off';
20
+ export declare const CHAT_MODE_IDS: readonly ["agent", "plan"];
21
+ export type ChatModeId = (typeof CHAT_MODE_IDS)[number];
22
+ /** 产品声明的对话模式。缺省 Agent + Plan;空/非法回落 Agent。 */
23
+ export declare function resolveChatModes(value?: unknown): ChatModeId[];
24
+ export declare function clampChatMode(requested: unknown, modes?: readonly ChatModeId[]): ChatModeId;
25
+ /** 设置入口:侧栏页 + 综合设置分段。缺省全开;`false` 只藏入口。 */
26
+ export declare const SETTINGS_ITEM_IDS: readonly ["agents", "skills", "mcp", "appearance", "llm", "web-search", "usage", "diagnose", "security", "insights", "telemetry"];
27
+ export type SettingsItemId = (typeof SETTINGS_ITEM_IDS)[number];
28
+ export declare const GENERAL_SETTINGS_ITEM_IDS: readonly ["appearance", "llm", "web-search", "usage", "diagnose", "security", "insights", "telemetry"];
29
+ export type SettingsChromeConfig = Partial<Record<SettingsItemId, boolean>>;
30
+ export type ResolvedSettingsChrome = Record<SettingsItemId, boolean>;
31
+ /**
32
+ * 产品 `settings`:未声明的项全开。关了的宿主工具族会一并藏对应设置段
33
+ * (skills←plugins、mcp、web-search←web)。
34
+ */
35
+ export declare function resolveSettingsChrome(value?: unknown, tools?: ResolvedHostTools): ResolvedSettingsChrome;
36
+ export declare function hasGeneralSettingsChrome(chrome?: ResolvedSettingsChrome): boolean;
37
+ export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "local_write_file", "local_edit_file", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script"];
38
+ export declare function resolveHostToolSurface(value: boolean | {
39
+ capability?: boolean;
40
+ chrome?: boolean;
41
+ } | undefined): HostToolFamilySurface;
42
+ /**
43
+ * 对象声明必须显式 `chrome: true` 才开入口。
44
+ * `capability` 缺省为 true——只关入口写成 `{ chrome: false }` 即可。
45
+ */
46
+ export declare function resolveHostTools(config?: HostToolsConfig | null): ResolvedHostTools;
47
+ export declare function resolveApprovalMode(value: unknown): ApprovalMode;
48
+ /**
49
+ * 产品 `approval: "off"` 或 STEERABLE_APPROVAL=0 时本轮不挂 host 审批。
50
+ * 环境变量是调试逃生口;产品字段是装配真源。
51
+ */
52
+ export declare function isApprovalEnabled(input?: {
53
+ productApproval?: unknown;
54
+ envApproval?: string;
55
+ }): boolean;
56
+ export declare function clampExecPolicy(requested: unknown, tools?: ResolvedHostTools): 'workspace' | 'full';
57
+ export declare function familyForHostToolName(name: string): HostToolFamilyId | null;
58
+ export declare function isHostToolCapabilityEnabled(name: string, tools?: ResolvedHostTools): boolean;
59
+ export declare function isHostRouteAllowed(pathname: string, tools?: ResolvedHostTools): boolean;
60
+ export declare function sanitizeRightPanelKind(kind: string | null | undefined, tools?: ResolvedHostTools): string | null;
61
+ /** Electron IPC 通道与 HTTP `/host/*` 用同一套 chrome / capability 规则。 */
62
+ export declare function isHostIpcAllowed(channel: string, tools?: ResolvedHostTools): boolean;
63
+ /** 本轮是否挂 host 审批对象;关掉时返回 undefined(sidecar 不弹询问)。 */
64
+ export declare function buildHostApproval(input: {
65
+ productApproval?: unknown;
66
+ envApproval?: string;
67
+ storePath: string;
68
+ timeoutMs?: number;
69
+ }): {
70
+ mode: 'host';
71
+ timeoutMs: number;
72
+ storePath: string;
73
+ } | undefined;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * 宿主工具族:产品声明引入哪些工具,服务端按同一份配置钳死围栏 / 路由 / 工具面。
3
+ * 渲染层只投影 chrome;缺省(中性 shell、未声明的族)全开,兼容存量产品。
4
+ */
5
+ export const HOST_TOOL_FAMILY_IDS = [
6
+ 'terminal',
7
+ 'local-fs',
8
+ 'projects',
9
+ 'background-tasks',
10
+ 'mcp',
11
+ 'web',
12
+ 'plugins',
13
+ ];
14
+ export const CHAT_MODE_IDS = ['agent', 'plan'];
15
+ const DEFAULT_CHAT_MODES = ['agent', 'plan'];
16
+ /** 产品声明的对话模式。缺省 Agent + Plan;空/非法回落 Agent。 */
17
+ export function resolveChatModes(value) {
18
+ if (!Array.isArray(value))
19
+ return [...DEFAULT_CHAT_MODES];
20
+ const allowed = CHAT_MODE_IDS.filter((id) => value.includes(id));
21
+ return allowed.length > 0 ? allowed : ['agent'];
22
+ }
23
+ export function clampChatMode(requested, modes = resolveChatModes()) {
24
+ return requested === 'plan' && modes.includes('plan') ? 'plan' : 'agent';
25
+ }
26
+ /** 设置入口:侧栏页 + 综合设置分段。缺省全开;`false` 只藏入口。 */
27
+ export const SETTINGS_ITEM_IDS = [
28
+ 'agents',
29
+ 'skills',
30
+ 'mcp',
31
+ 'appearance',
32
+ 'llm',
33
+ 'web-search',
34
+ 'usage',
35
+ 'diagnose',
36
+ 'security',
37
+ 'insights',
38
+ 'telemetry',
39
+ ];
40
+ export const GENERAL_SETTINGS_ITEM_IDS = [
41
+ 'appearance',
42
+ 'llm',
43
+ 'web-search',
44
+ 'usage',
45
+ 'diagnose',
46
+ 'security',
47
+ 'insights',
48
+ 'telemetry',
49
+ ];
50
+ function settingsFollowsHostTool(id, tools) {
51
+ if (id === 'skills')
52
+ return tools.plugins.chrome;
53
+ if (id === 'mcp')
54
+ return tools.mcp.chrome;
55
+ if (id === 'web-search')
56
+ return tools.web.chrome;
57
+ return true;
58
+ }
59
+ /**
60
+ * 产品 `settings`:未声明的项全开。关了的宿主工具族会一并藏对应设置段
61
+ * (skills←plugins、mcp、web-search←web)。
62
+ */
63
+ export function resolveSettingsChrome(value, tools = resolveHostTools()) {
64
+ const config = value && typeof value === 'object' && !Array.isArray(value)
65
+ ? value
66
+ : {};
67
+ const out = {};
68
+ for (const id of SETTINGS_ITEM_IDS) {
69
+ out[id] = config[id] !== false && settingsFollowsHostTool(id, tools);
70
+ }
71
+ return out;
72
+ }
73
+ export function hasGeneralSettingsChrome(chrome = resolveSettingsChrome()) {
74
+ return GENERAL_SETTINGS_ITEM_IDS.some((id) => chrome[id]);
75
+ }
76
+ export const LOCAL_FS_TOOL_NAMES = [
77
+ 'local_exec_shell',
78
+ 'local_read_file',
79
+ 'local_write_file',
80
+ 'local_edit_file',
81
+ 'local_open_path',
82
+ 'local_run_snippet',
83
+ 'local_list_scripts',
84
+ 'local_run_script',
85
+ ];
86
+ const FAMILY_TOOL_PREFIX = {
87
+ terminal: [],
88
+ projects: [],
89
+ 'local-fs': LOCAL_FS_TOOL_NAMES,
90
+ 'background-tasks': [
91
+ 'task_run',
92
+ 'task_send',
93
+ 'task_status',
94
+ 'task_result',
95
+ 'worktree_create',
96
+ 'worktree_list',
97
+ 'worktree_remove',
98
+ ],
99
+ mcp: ['mcp_list_tools', 'mcp_tool_exec'],
100
+ web: ['web_search', 'web_fetch'],
101
+ plugins: ['plugin_list', 'plugin_enable', 'plugin_disable', 'plugin_reload'],
102
+ };
103
+ const ALL_ON = { capability: true, chrome: true };
104
+ const ALL_OFF = { capability: false, chrome: false };
105
+ export function resolveHostToolSurface(value) {
106
+ if (value === undefined)
107
+ return { ...ALL_ON };
108
+ if (value === true)
109
+ return { ...ALL_ON };
110
+ if (value === false)
111
+ return { ...ALL_OFF };
112
+ return {
113
+ capability: value.capability !== false,
114
+ chrome: value.chrome === true,
115
+ };
116
+ }
117
+ /**
118
+ * 对象声明必须显式 `chrome: true` 才开入口。
119
+ * `capability` 缺省为 true——只关入口写成 `{ chrome: false }` 即可。
120
+ */
121
+ export function resolveHostTools(config) {
122
+ const out = {};
123
+ for (const id of HOST_TOOL_FAMILY_IDS) {
124
+ out[id] = resolveHostToolSurface(config?.[id]);
125
+ }
126
+ return out;
127
+ }
128
+ export function resolveApprovalMode(value) {
129
+ return value === 'off' ? 'off' : 'host';
130
+ }
131
+ /**
132
+ * 产品 `approval: "off"` 或 STEERABLE_APPROVAL=0 时本轮不挂 host 审批。
133
+ * 环境变量是调试逃生口;产品字段是装配真源。
134
+ */
135
+ export function isApprovalEnabled(input = {}) {
136
+ if (input.envApproval === '0')
137
+ return false;
138
+ return resolveApprovalMode(input.productApproval) === 'host';
139
+ }
140
+ export function clampExecPolicy(requested, tools = resolveHostTools()) {
141
+ if (!tools['local-fs'].chrome)
142
+ return 'workspace';
143
+ return requested === 'full' ? 'full' : 'workspace';
144
+ }
145
+ export function familyForHostToolName(name) {
146
+ if (name.startsWith('mcp__'))
147
+ return 'mcp';
148
+ for (const id of HOST_TOOL_FAMILY_IDS) {
149
+ if (FAMILY_TOOL_PREFIX[id].includes(name))
150
+ return id;
151
+ }
152
+ return null;
153
+ }
154
+ export function isHostToolCapabilityEnabled(name, tools = resolveHostTools()) {
155
+ const family = familyForHostToolName(name);
156
+ return family == null ? true : tools[family].capability;
157
+ }
158
+ export function isHostRouteAllowed(pathname, tools = resolveHostTools()) {
159
+ if (pathname.startsWith('/host/terminal'))
160
+ return tools.terminal.chrome;
161
+ if (pathname === '/host/attachments/save' || pathname === '/host/local/open-path') {
162
+ return tools['local-fs'].chrome;
163
+ }
164
+ if (pathname.startsWith('/host/local/'))
165
+ return tools['local-fs'].capability;
166
+ return true;
167
+ }
168
+ export function sanitizeRightPanelKind(kind, tools = resolveHostTools()) {
169
+ if (!kind)
170
+ return null;
171
+ if (kind === 'terminal')
172
+ return tools.terminal.chrome ? 'terminal' : null;
173
+ return kind;
174
+ }
175
+ /** Electron IPC 通道与 HTTP `/host/*` 用同一套 chrome / capability 规则。 */
176
+ export function isHostIpcAllowed(channel, tools = resolveHostTools()) {
177
+ if (channel.startsWith('terminal:'))
178
+ return tools.terminal.chrome;
179
+ if (channel === 'local:open-path' || channel === 'attachments:save') {
180
+ return tools['local-fs'].chrome;
181
+ }
182
+ if (channel.startsWith('local:'))
183
+ return tools['local-fs'].capability;
184
+ return true;
185
+ }
186
+ /** 本轮是否挂 host 审批对象;关掉时返回 undefined(sidecar 不弹询问)。 */
187
+ export function buildHostApproval(input) {
188
+ if (!isApprovalEnabled(input))
189
+ return undefined;
190
+ return {
191
+ mode: 'host',
192
+ timeoutMs: input.timeoutMs ?? 120_000,
193
+ storePath: input.storePath,
194
+ };
195
+ }
@@ -98,16 +98,19 @@ export declare class LocalBackendRouter {
98
98
  private runTitleGenInBackground;
99
99
  /**
100
100
  * 解析当前对话绑定的项目(项目模式)。chat 没绑项目、项目已被删除、或
101
- * 注册表未注入时都返回 null(= 不沙箱,行为同旧版本)。
102
- *
103
- * Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
104
- * 工具调用补上 projectRoot 围栏。
101
+ * 注册表未注入时都返回 null。无项目对话的可写根见
102
+ * {@link LocalBackendRouter.resolveChatWorkspaceRoot}。
105
103
  */
106
104
  resolveChatProject(chatId: string): Promise<{
107
105
  name: string;
108
106
  folderPath: string;
109
107
  sourceFolders: string[];
110
108
  } | null>;
109
+ /**
110
+ * 本对话的可写根:绑了项目用项目家目录,否则在
111
+ * `Documents/<应用名>/conversations/<chatId>/` 建对话工作区。
112
+ */
113
+ resolveChatWorkspaceRoot(chatId: string): Promise<string>;
111
114
  /**
112
115
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
113
116
  *
@@ -22,6 +22,8 @@ import { sanitizeCompatOverrides, sanitizePresetsChoice, sanitizeLlmProvider, sa
22
22
  import { setDefaultExecTimeoutMs } from '../local-executor.js';
23
23
  import { allowEgressForBaseUrl, getActiveEgressBroker, getEgressPosture } from '../sidecar/egress-proxy.js';
24
24
  import { buildExecSandbox, parseExecPolicy } from '../sidecar/exec-sandbox.js';
25
+ import { clampExecPolicy } from '../host-tools.js';
26
+ import { getResolvedHostTools, isProductLlmLocked, resolveRuntimeLlmSettings, resolveTurnApproval, resolveTurnChatMode, } from '../host-tools-runtime.js';
25
27
  import { SidecarSupervisor } from '../sidecar/index.js';
26
28
  import { diagnoseLlmConnection } from './llm-diagnose.js';
27
29
  import { brandSkillVars, buildSystemPrompt, buildForcedSkillMessage, conditionsFromTools, } from './prompt-builder.js';
@@ -38,7 +40,7 @@ import { detectInterruptedTurn } from './interrupted-helper.js';
38
40
  import { dropCurrentUserMessage } from './history-helper.js';
39
41
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
40
42
  import { loadProjectRuleFiles } from '../project-rules.js';
41
- import { allocateProjectHome, ensureProjectHome } from '../project-home.js';
43
+ import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
42
44
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
43
45
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
44
46
  import { matchPackBackendRoute } from './pack-backend-routes.js';
@@ -48,7 +50,9 @@ import { getAuthProvider } from '../auth/index.js';
48
50
  function parseSourceFolders(value) {
49
51
  if (!Array.isArray(value))
50
52
  return [];
51
- return value.filter((item) => typeof item === 'string' && item.trim() !== '');
53
+ return value
54
+ .filter((item) => typeof item === 'string' && item.trim() !== '')
55
+ .map(expandUserPath);
52
56
  }
53
57
  /**
54
58
  * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
@@ -391,6 +395,9 @@ export class LocalBackendRouter {
391
395
  const projectId = typeof payload.projectId === 'string' && payload.projectId.trim()
392
396
  ? payload.projectId.trim()
393
397
  : null;
398
+ if (projectId && !getResolvedHostTools().projects.capability) {
399
+ return { status: 403, data: { detail: 'Projects are disabled for this product' } };
400
+ }
394
401
  if (projectId) {
395
402
  const project = this.toolRouter.projectRegistry?.get(projectId);
396
403
  if (!project) {
@@ -426,6 +433,10 @@ export class LocalBackendRouter {
426
433
  // ───── 项目模式:projects CRUD ─────
427
434
  // 项目记录存 electron-store(agent-projects.json),chat.project_id 存
428
435
  // SQLite。删除项目不删会话——会话降级为无项目对话。
436
+ if ((pathname === '/api/v2/projects' || pathname.startsWith('/api/v2/projects/')) &&
437
+ !getResolvedHostTools().projects.capability) {
438
+ return { status: 403, data: { error: 'Projects are disabled for this product' } };
439
+ }
429
440
  if (pathname === '/api/v2/projects') {
430
441
  const registry = this.toolRouter.projectRegistry;
431
442
  if (!registry) {
@@ -439,7 +450,7 @@ export class LocalBackendRouter {
439
450
  try {
440
451
  const name = String(payload.name || '');
441
452
  const sourceFolders = parseSourceFolders(payload.sourceFolders);
442
- let folderPath = typeof payload.folderPath === 'string' ? payload.folderPath.trim() : '';
453
+ let folderPath = typeof payload.folderPath === 'string' ? expandUserPath(payload.folderPath) : '';
443
454
  if (!folderPath) {
444
455
  folderPath = allocateProjectHome(name);
445
456
  ensureProjectHome(folderPath);
@@ -471,7 +482,7 @@ export class LocalBackendRouter {
471
482
  try {
472
483
  const project = registry.update(projectId, {
473
484
  name: typeof payload.name === 'string' ? payload.name : undefined,
474
- folderPath: typeof payload.folderPath === 'string' ? payload.folderPath : undefined,
485
+ folderPath: typeof payload.folderPath === 'string' ? expandUserPath(payload.folderPath) : undefined,
475
486
  sourceFolders: payload.sourceFolders === undefined
476
487
  ? undefined
477
488
  : parseSourceFolders(payload.sourceFolders),
@@ -679,6 +690,9 @@ export class LocalBackendRouter {
679
690
  // (必须真实存在,防止把会话挂到幽灵项目上)。
680
691
  let projectIdUpdate;
681
692
  if ('projectId' in payload) {
693
+ if (!getResolvedHostTools().projects.capability) {
694
+ return { status: 403, data: { detail: 'Projects are disabled for this product' } };
695
+ }
682
696
  if (payload.projectId === null) {
683
697
  projectIdUpdate = null;
684
698
  }
@@ -1463,10 +1477,13 @@ export class LocalBackendRouter {
1463
1477
  if (method === 'GET') {
1464
1478
  return {
1465
1479
  status: 200,
1466
- data: await this.store.getLlmSettings(),
1480
+ data: resolveRuntimeLlmSettings(await this.store.getLlmSettings()),
1467
1481
  };
1468
1482
  }
1469
1483
  if (method === 'POST') {
1484
+ if (isProductLlmLocked()) {
1485
+ return { status: 403, data: { error: 'LLM settings are product-locked' } };
1486
+ }
1470
1487
  const payload = this.toRecord(request.body);
1471
1488
  const saved = await llmService.setSettings({
1472
1489
  provider: sanitizeLlmProvider(payload.provider),
@@ -1749,7 +1766,7 @@ export class LocalBackendRouter {
1749
1766
  }
1750
1767
  // 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
1751
1768
  // 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
1752
- // 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
1769
+ // 路径按对话绑定的项目根解析,无项目时按对话工作区。
1753
1770
  if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
1754
1771
  const payload = this.toRecord(request.body);
1755
1772
  const candidates = Array.isArray(payload.candidates)
@@ -1759,7 +1776,9 @@ export class LocalBackendRouter {
1759
1776
  return { status: 200, data: { resolved: [] } };
1760
1777
  }
1761
1778
  const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
1762
- const baseDir = (chatId ? (await this.resolveChatProject(chatId))?.folderPath : null) ?? os.homedir();
1779
+ const baseDir = chatId
1780
+ ? await this.resolveChatWorkspaceRoot(chatId)
1781
+ : os.homedir();
1763
1782
  const resolved = await resolveMentionedPaths({ candidates, baseDir });
1764
1783
  return { status: 200, data: { resolved } };
1765
1784
  }
@@ -2018,7 +2037,7 @@ export class LocalBackendRouter {
2018
2037
  }
2019
2038
  // Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
2020
2039
  // 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
2021
- const chatMode = payload.mode === 'plan' ? 'plan' : 'agent';
2040
+ const chatMode = resolveTurnChatMode(payload.mode);
2022
2041
  // 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
2023
2042
  // 策略决定工具列表,工具列表又决定技能的触发条件。
2024
2043
  const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
@@ -2190,10 +2209,8 @@ export class LocalBackendRouter {
2190
2209
  }
2191
2210
  /**
2192
2211
  * 解析当前对话绑定的项目(项目模式)。chat 没绑项目、项目已被删除、或
2193
- * 注册表未注入时都返回 null(= 不沙箱,行为同旧版本)。
2194
- *
2195
- * Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
2196
- * 工具调用补上 projectRoot 围栏。
2212
+ * 注册表未注入时都返回 null。无项目对话的可写根见
2213
+ * {@link LocalBackendRouter.resolveChatWorkspaceRoot}。
2197
2214
  */
2198
2215
  async resolveChatProject(chatId) {
2199
2216
  const projectId = (await this.store.getChat(chatId))?.projectId;
@@ -2208,6 +2225,16 @@ export class LocalBackendRouter {
2208
2225
  sourceFolders: project.sourceFolders ?? [],
2209
2226
  };
2210
2227
  }
2228
+ /**
2229
+ * 本对话的可写根:绑了项目用项目家目录,否则在
2230
+ * `Documents/<应用名>/conversations/<chatId>/` 建对话工作区。
2231
+ */
2232
+ async resolveChatWorkspaceRoot(chatId) {
2233
+ const project = await this.resolveChatProject(chatId);
2234
+ if (project)
2235
+ return project.folderPath;
2236
+ return ensureChatWorkspace(chatId);
2237
+ }
2211
2238
  /**
2212
2239
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
2213
2240
  *
@@ -2396,6 +2423,9 @@ export class LocalBackendRouter {
2396
2423
  // 都追加)。硬围栏在 ToolRouter/LocalExecutor,这里让模型事先知道边界,
2397
2424
  // 减少越界尝试。
2398
2425
  const chatProject = await this.resolveChatProject(chatId);
2426
+ const workspaceRoot = chatProject
2427
+ ? chatProject.folderPath
2428
+ : await this.resolveChatWorkspaceRoot(chatId);
2399
2429
  if (chatProject) {
2400
2430
  systemPrompt +=
2401
2431
  `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,家目录:${chatProject.folderPath}\n` +
@@ -2423,6 +2453,14 @@ export class LocalBackendRouter {
2423
2453
  }
2424
2454
  }
2425
2455
  }
2456
+ else {
2457
+ systemPrompt +=
2458
+ `\n\n【对话工作区】当前对话没有绑定项目。工作区:${workspaceRoot}\n` +
2459
+ `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该目录内:` +
2460
+ `写入路径越界会被拒绝;命令默认在工作区下运行,显式指定的 cwd 越界也会被拒绝。` +
2461
+ `请一律使用工作区内的路径(相对路径按工作区解析)。` +
2462
+ `用户若要把对话绑到已有项目,可在输入框上方选择项目。`;
2463
+ }
2426
2464
  // @引用的历史对话(payload.referencedChatIds):把被引用对话的最近消息
2427
2465
  // 摘录注入为 user 消息,放在本对话历史之前。
2428
2466
  const referencedChatMessages = await this.buildReferencedChatContextMessages(payload, chatId);
@@ -2877,29 +2915,25 @@ export class LocalBackendRouter {
2877
2915
  // spec (default.harness.yaml) is the single source of truth for
2878
2916
  // maxRounds / maxToolErrors, and an explicit request param overrides it.
2879
2917
  // W4-2: per-exec sandbox for shell tool calls, default-on. Writable
2880
- // roots = the chat's project root (project mode) — unbound chats get an
2881
- // empty list, so writes confine to system scratch dirs only.
2918
+ // roots = 项目家目录,或无项目时 Documents/<应用>/conversations/<chatId>。
2882
2919
  // `execPolicy: 'full'`(输入框「完整权限」)关闭这一层,让 mkdir
2883
- // Downloads 这类项目外写入不再被 Seatbelt 拦成 Operation not permitted。
2920
+ // Downloads 这类工作区外写入不再被 Seatbelt 拦成 Operation not permitted。
2884
2921
  const chatProject = await this.resolveChatProject(chatId);
2922
+ const workspaceRoot = chatProject
2923
+ ? chatProject.folderPath
2924
+ : await this.resolveChatWorkspaceRoot(chatId);
2885
2925
  const execSandbox = buildExecSandbox([
2886
2926
  // 场景包声明的每会话可写根(如文档包在项目根之外落盘产物的
2887
2927
  // 工作区)。
2888
2928
  ...collectPackExecWritableRoots(chatId),
2889
- ...(chatProject ? [chatProject.folderPath] : []),
2890
- ], { policy: parseExecPolicy(payload.execPolicy) });
2929
+ workspaceRoot,
2930
+ ], { policy: clampExecPolicy(parseExecPolicy(payload.execPolicy), getResolvedHostTools()) });
2891
2931
  // W4-1: approval algebra, host mode — every tool call is gated by the
2892
2932
  // Electron approval UI over the reverse channel. The durable scope
2893
2933
  // (allow_always / deny_always) persists per tool category under
2894
- // ~/.steerable (writable inside the layer-1 sandbox). STEERABLE_APPROVAL=0
2895
- // restores the legacy ungated behavior.
2896
- const approval = process.env.STEERABLE_APPROVAL === '0'
2897
- ? undefined
2898
- : {
2899
- mode: 'host',
2900
- timeoutMs: 120_000,
2901
- storePath: path.join(os.homedir(), '.steerable', 'approvals.json'),
2902
- };
2934
+ // ~/.steerable (writable inside the layer-1 sandbox). 产品
2935
+ // approval:"off" 或 STEERABLE_APPROVAL=0 本轮不挂审批。
2936
+ const approval = resolveTurnApproval();
2903
2937
  let assistantText = '';
2904
2938
  const executedActions = [];
2905
2939
  const timeline = [];
@@ -2929,7 +2963,7 @@ export class LocalBackendRouter {
2929
2963
  const turnStartedAtMs = Date.now();
2930
2964
  const turnFileRoots = [
2931
2965
  ...collectPackExecWritableRoots(chatId),
2932
- ...(chatProject ? [chatProject.folderPath] : []),
2966
+ workspaceRoot,
2933
2967
  ];
2934
2968
  try {
2935
2969
  const coreLoopOptions = {
@@ -3219,7 +3253,7 @@ export class LocalBackendRouter {
3219
3253
  roots: turnFileRoots,
3220
3254
  sinceMs: turnStartedAtMs,
3221
3255
  actions: executedActions,
3222
- projectRoot: chatProject?.folderPath ?? null,
3256
+ projectRoot: workspaceRoot,
3223
3257
  });
3224
3258
  }
3225
3259
  catch (turnFilesErr) {
@@ -55,6 +55,11 @@ export interface TaskServiceDeps {
55
55
  name: string;
56
56
  folderPath: string;
57
57
  } | null>;
58
+ /**
59
+ * 无项目对话的可写根(Documents/<应用>/conversations/<chatId>)。
60
+ * 测试可不传,围栏回落为仅 scratch。
61
+ */
62
+ resolveChatWorkspaceRoot?: (chatId: string) => Promise<string>;
58
63
  /** 任务终态 / 推理过程广播(renderer 任务面板与右侧过程栏)。 */
59
64
  broadcast?: (eventName: 'task-updated' | 'task-process', payload: Record<string, unknown>) => void;
60
65
  /** 测试缝:替换真实流驱动(默认 streamCoreLoopTurn)。 */
@@ -24,12 +24,12 @@
24
24
  * 没有 `task_run`,任务不能再派生任务(任务里仍可 delegate_subagent)。
25
25
  */
26
26
  import path from 'node:path';
27
- import os from 'node:os';
28
27
  import { llmService, getSidecarSupervisor } from '../llm/index.js';
29
28
  import { sidecarWireProvider } from '../storage/llm-settings.js';
30
29
  import { buildWorldState, streamCoreLoopTurn, } from './coreloop-stream.js';
31
30
  import { builtinSubagentParam } from './subagent-profiles.js';
32
31
  import { buildExecSandbox } from '../sidecar/exec-sandbox.js';
32
+ import { resolveTurnApproval } from '../host-tools-runtime.js';
33
33
  import { readSidecarHistoryEntries, timelineFromHistoryEntries, } from './task-process.js';
34
34
  import { appendTimelineDelta, syncTimelineTools, } from './turn-timeline.js';
35
35
  /** 任务回合的系统提示词:自包含、汇报结果、不假装执行。 */
@@ -272,16 +272,14 @@ export class TaskService {
272
272
  const settings = llmService.getSettings();
273
273
  const project = await this.deps.resolveChatProject(chatId);
274
274
  // 4.6b 整合:worktree 任务的围栏根 = worktree 路径(更紧——任务摸不到
275
- // 主检出);否则沿用项目根。writableRoots 同理收窄。
276
- const fenceRoot = worktreePath ?? project?.folderPath ?? null;
275
+ // 主检出);否则项目家目录,再否则对话工作区。
276
+ const fenceRoot = worktreePath ??
277
+ project?.folderPath ??
278
+ (this.deps.resolveChatWorkspaceRoot
279
+ ? await this.deps.resolveChatWorkspaceRoot(chatId)
280
+ : null);
277
281
  const execSandbox = buildExecSandbox(fenceRoot ? [fenceRoot] : []);
278
- const approval = process.env.STEERABLE_APPROVAL === '0'
279
- ? undefined
280
- : {
281
- mode: 'host',
282
- timeoutMs: 120_000,
283
- storePath: path.join(os.homedir(), '.steerable', 'approvals.json'),
284
- };
282
+ const approval = resolveTurnApproval();
285
283
  // depth-1:任务回合没有 task_run(不能再派生任务),其余工具面与
286
284
  // 普通回合一致;task_status / task_result 保留(任务可以自查/互查)。
287
285
  const tools = this.deps.toolRouter
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ import log from 'electron-log';
11
11
  import { getBrand } from './brand.js';
12
12
  import { getPreloadPath } from './runtime.js';
13
13
  import { getProductConfig } from './product-config.js';
14
+ import { assertHostIpcAllowed, getResolvedHostTools } from './host-tools-runtime.js';
14
15
  import { createJsonStore } from './json-store.js';
15
16
  import { getUserDataDir } from './runtime.js';
16
17
  import { saveAttachmentFiles } from './attachments.js';
@@ -428,19 +429,23 @@ function createMenu() {
428
429
  {
429
430
  label: '视图',
430
431
  submenu: [
431
- {
432
- label: '打开终端',
433
- accelerator: 'CmdOrCtrl+T',
434
- click: () => {
435
- // 终端内嵌在主窗口的 /terminal 路由——通知 renderer 导航过去。
436
- if (mainWindow && !mainWindow.isDestroyed()) {
437
- mainWindow.show();
438
- mainWindow.focus();
439
- mainWindow.webContents.send('menu:open-terminal');
440
- }
441
- },
442
- },
443
- { type: 'separator' },
432
+ ...(getResolvedHostTools().terminal.chrome
433
+ ? [
434
+ {
435
+ label: '打开终端',
436
+ accelerator: 'CmdOrCtrl+T',
437
+ click: () => {
438
+ // 终端内嵌在主窗口的 /terminal 路由——通知 renderer 导航过去。
439
+ if (mainWindow && !mainWindow.isDestroyed()) {
440
+ mainWindow.show();
441
+ mainWindow.focus();
442
+ mainWindow.webContents.send('menu:open-terminal');
443
+ }
444
+ },
445
+ },
446
+ { type: 'separator' },
447
+ ]
448
+ : []),
444
449
  { role: 'reload', label: '重新加载' },
445
450
  { role: 'forceReload', label: '强制重新加载' },
446
451
  { type: 'separator' },
@@ -558,6 +563,7 @@ function setupIpcHandlers() {
558
563
  });
559
564
  // 会话附件:把渲染进程给出的源路径(Electron)或 base64 字节(BS)落盘。
560
565
  ipcMain.handle('attachments:save', async (_event, input) => {
566
+ assertHostIpcAllowed('attachments:save');
561
567
  const chatId = typeof input?.chatId === 'string' ? input.chatId : '';
562
568
  const files = Array.isArray(input?.files)
563
569
  ? input.files.filter((f) => f && (typeof f.path === 'string' || typeof f.data === 'string'))
@@ -612,7 +618,10 @@ function setupIpcHandlers() {
612
618
  });
613
619
  ipcMain.handle('local:read-file', async (_event, request) => localExecutor.readLocalFile(request));
614
620
  ipcMain.handle('local:write-file', async (_event, request) => localExecutor.writeLocalFile(request));
615
- ipcMain.handle('local:open-path', async (_event, request) => localExecutor.openLocalTarget(request));
621
+ ipcMain.handle('local:open-path', async (_event, request) => {
622
+ assertHostIpcAllowed('local:open-path');
623
+ return localExecutor.openLocalTarget(request);
624
+ });
616
625
  ipcMain.handle('local:list-scripts', async () => localScriptRegistry.list());
617
626
  ipcMain.handle('local:add-script', async (_event, input) => localScriptRegistry.create(input));
618
627
  ipcMain.handle('local:update-script', async (_event, payload) => localScriptRegistry.update(payload.id, payload.updates));
@@ -748,11 +757,16 @@ function setupIpcHandlers() {
748
757
  // ---------------------------------------------------------------------------
749
758
  // Terminal IPC: visible PTY embedded in the main window's /terminal route
750
759
  // ---------------------------------------------------------------------------
751
- ipcMain.handle('terminal:list', async () => terminalManager.list());
760
+ ipcMain.handle('terminal:list', async () => {
761
+ assertHostIpcAllowed('terminal:list');
762
+ return terminalManager.list();
763
+ });
752
764
  ipcMain.handle('terminal:spawn', async (_event, options = {}) => {
765
+ assertHostIpcAllowed('terminal:spawn');
753
766
  return terminalManager.spawn(options);
754
767
  });
755
768
  ipcMain.handle('terminal:ensure', async (event, options = {}) => {
769
+ assertHostIpcAllowed('terminal:ensure');
756
770
  const session = terminalManager.ensurePrimary(options);
757
771
  // Replay any output buffered before the renderer was attached, so the
758
772
  // window doesn't look empty if the agent kicked off a command before the
@@ -770,13 +784,19 @@ function setupIpcHandlers() {
770
784
  return session;
771
785
  });
772
786
  ipcMain.handle('terminal:write', async (_event, payload) => {
787
+ assertHostIpcAllowed('terminal:write');
773
788
  return terminalManager.write(payload.id, payload.data);
774
789
  });
775
790
  ipcMain.handle('terminal:resize', async (_event, payload) => {
791
+ assertHostIpcAllowed('terminal:resize');
776
792
  return terminalManager.resize(payload.id, payload.cols, payload.rows);
777
793
  });
778
- ipcMain.handle('terminal:kill', async (_event, id) => terminalManager.kill(id));
794
+ ipcMain.handle('terminal:kill', async (_event, id) => {
795
+ assertHostIpcAllowed('terminal:kill');
796
+ return terminalManager.kill(id);
797
+ });
779
798
  ipcMain.handle('terminal:exec', async (_event, payload) => {
799
+ assertHostIpcAllowed('terminal:exec');
780
800
  let id = payload.id;
781
801
  if (!id) {
782
802
  const session = terminalManager.ensurePrimary();
@@ -0,0 +1,14 @@
1
+ export interface NativeFolderDialogResult {
2
+ canceled: boolean;
3
+ filePaths: string[];
4
+ }
5
+ export interface NativeFolderDialogOptions {
6
+ title?: string;
7
+ }
8
+ export interface NativeFolderDialogDeps {
9
+ platform?: NodeJS.Platform;
10
+ run?: (cmd: string, args: readonly string[]) => Promise<{
11
+ stdout: string;
12
+ }>;
13
+ }
14
+ export declare function selectNativeDirectory(options?: NativeFolderDialogOptions, deps?: NativeFolderDialogDeps): Promise<NativeFolderDialogResult>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * BS 宿主没有 Electron `dialog.showOpenDialog`(`ELECTRON_RUN_AS_NODE=1`)。
3
+ * 本模块用各平台系统选择器弹出「选文件夹」,返回与 preload
4
+ * `local.selectDirectory` 相同的 `{ canceled, filePaths }`。
5
+ *
6
+ * 取消、无 DISPLAY、命令不存在都记为 canceled,让 UI 走手动输入路径。
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ const execFileAsync = promisify(execFile);
11
+ function sanitizePrompt(title) {
12
+ const raw = (title ?? '选择文件夹').replace(/[\r\n]+/g, ' ').slice(0, 80);
13
+ return raw.replace(/["\\]/g, '');
14
+ }
15
+ function picked(stdout) {
16
+ const folder = stdout.trim().replace(/[/\\]+$/, '');
17
+ return folder ? { canceled: false, filePaths: [folder] } : { canceled: true, filePaths: [] };
18
+ }
19
+ export async function selectNativeDirectory(options = {}, deps = {}) {
20
+ const platform = deps.platform ?? process.platform;
21
+ const run = deps.run ?? ((cmd, args) => execFileAsync(cmd, [...args]));
22
+ const title = sanitizePrompt(options.title);
23
+ try {
24
+ if (platform === 'darwin') {
25
+ const { stdout } = await run('osascript', [
26
+ '-e',
27
+ `POSIX path of (choose folder with prompt "${title}")`,
28
+ ]);
29
+ return picked(stdout);
30
+ }
31
+ if (platform === 'win32') {
32
+ const escaped = title.replace(/'/g, "''");
33
+ const script = [
34
+ 'Add-Type -AssemblyName System.Windows.Forms',
35
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
36
+ `$d.Description = '${escaped}'`,
37
+ 'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }',
38
+ ].join('; ');
39
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]);
40
+ return picked(stdout);
41
+ }
42
+ try {
43
+ const { stdout } = await run('zenity', [
44
+ '--file-selection',
45
+ '--directory',
46
+ `--title=${title}`,
47
+ ]);
48
+ return picked(stdout);
49
+ }
50
+ catch {
51
+ const { stdout } = await run('kdialog', [
52
+ '--getexistingdirectory',
53
+ process.env.HOME || '/',
54
+ title,
55
+ ]);
56
+ return picked(stdout);
57
+ }
58
+ }
59
+ catch {
60
+ return { canceled: true, filePaths: [] };
61
+ }
62
+ }
@@ -33,6 +33,41 @@ export interface ProductConfig {
33
33
  * 数据;中性 shell 缺省 'agent-shell.db'。
34
34
  */
35
35
  dbFileName?: string;
36
+ /**
37
+ * 宿主工具族:产品声明引入哪些工具。缺省全开。
38
+ * 形状由 host-tools 解析;本模块只存声明、不依赖解析器。
39
+ */
40
+ hostTools?: Record<string, boolean | {
41
+ capability?: boolean;
42
+ chrome?: boolean;
43
+ }>;
44
+ /**
45
+ * 命令安全询问:host = 弹宿主审批;off = 本轮不挂审批。缺省 host。
46
+ * STEERABLE_APPROVAL=0 仍是调试逃生口。
47
+ */
48
+ approval?: 'host' | 'off';
49
+ /**
50
+ * 对话模式。缺省 `['agent','plan']`。只留一种时渲染层不显示切换。
51
+ */
52
+ chatModes?: Array<'agent' | 'plan'>;
53
+ /**
54
+ * 设置入口。缺省全开。`false` 藏对应侧栏页或综合设置分段。
55
+ */
56
+ settings?: Record<string, boolean>;
57
+ /**
58
+ * 产品钉死的大模型。`settings.llm === false` 时必填,运行时用这份,
59
+ * 不再读设置页。密钥用 `apiKeyEnv` 指向环境变量,不要把 key 写进仓库。
60
+ */
61
+ llm?: {
62
+ provider?: string;
63
+ vendorId?: string;
64
+ model?: string;
65
+ baseUrl?: string;
66
+ apiKey?: string;
67
+ apiKeyEnv?: string;
68
+ temperature?: number;
69
+ maxTotalTokens?: number;
70
+ };
36
71
  }
37
72
  /**
38
73
  * 注入产品配置(产品组装根在第一个 import 时调用)。重复注入抛错
@@ -41,3 +76,5 @@ export interface ProductConfig {
41
76
  export declare function setProductConfig(config: ProductConfig): void;
42
77
  /** 读取已注入的产品配置;未注入返回空对象(中性框架行为)。 */
43
78
  export declare function getProductConfig(): ProductConfig;
79
+ /** 测试用:清掉已注入的产品配置,避免用例互相污染。 */
80
+ export declare function resetProductConfigForTests(): void;
@@ -24,3 +24,7 @@ export function setProductConfig(config) {
24
24
  export function getProductConfig() {
25
25
  return productConfig ?? {};
26
26
  }
27
+ /** 测试用:清掉已注入的产品配置,避免用例互相污染。 */
28
+ export function resetProductConfigForTests() {
29
+ productConfig = null;
30
+ }
@@ -5,6 +5,8 @@
5
5
  * 家目录由本模块分配并创建。用户另加的源文件夹是附加只读根,
6
6
  * 不替代这个家目录。
7
7
  */
8
+ /** 把 `~` / `~/…` 展开成绝对路径;其它输入原样(已 trim)。 */
9
+ export declare function expandUserPath(folder: string): string;
8
10
  export declare function sanitizeProjectDirName(name: string): string;
9
11
  /** `Documents/<应用显示名>`:该应用下所有托管项目的父目录。 */
10
12
  export declare function appProjectsRoot(options?: {
@@ -21,3 +23,18 @@ export declare function allocateProjectHome(projectName: string, options?: {
21
23
  exists?: (folderPath: string) => boolean;
22
24
  }): string;
23
25
  export declare function ensureProjectHome(folderPath: string): void;
26
+ /** 无项目对话的工作区子目录名(与托管项目家目录并列,避免撞名)。 */
27
+ export declare const CHAT_WORKSPACES_DIR = "conversations";
28
+ /**
29
+ * 无项目对话的工作区路径:`Documents/<应用名>/conversations/<chatId>/`。
30
+ * 不落盘;调用方用 {@link ensureChatWorkspace} 创建。
31
+ */
32
+ export declare function chatWorkspacePath(chatId: string, options?: {
33
+ documentsDir?: string;
34
+ appFolderName?: string;
35
+ }): string;
36
+ /** 确保无项目对话工作区存在,返回绝对路径。 */
37
+ export declare function ensureChatWorkspace(chatId: string, options?: {
38
+ documentsDir?: string;
39
+ appFolderName?: string;
40
+ }): string;
@@ -6,10 +6,21 @@
6
6
  * 不替代这个家目录。
7
7
  */
8
8
  import fs from 'node:fs';
9
+ import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  import { getBrand } from './brand.js';
11
12
  import { getDocumentsDir } from './runtime.js';
12
13
  const UNSAFE_DIR_CHARS = /[\\/:*?"<>|]/g;
14
+ /** 把 `~` / `~/…` 展开成绝对路径;其它输入原样(已 trim)。 */
15
+ export function expandUserPath(folder) {
16
+ const trimmed = folder.trim();
17
+ if (trimmed === '~')
18
+ return os.homedir();
19
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
20
+ return path.join(os.homedir(), trimmed.slice(2));
21
+ }
22
+ return trimmed;
23
+ }
13
24
  export function sanitizeProjectDirName(name) {
14
25
  const trimmed = name
15
26
  .trim()
@@ -45,3 +56,19 @@ export function allocateProjectHome(projectName, options) {
45
56
  export function ensureProjectHome(folderPath) {
46
57
  fs.mkdirSync(folderPath, { recursive: true });
47
58
  }
59
+ /** 无项目对话的工作区子目录名(与托管项目家目录并列,避免撞名)。 */
60
+ export const CHAT_WORKSPACES_DIR = 'conversations';
61
+ /**
62
+ * 无项目对话的工作区路径:`Documents/<应用名>/conversations/<chatId>/`。
63
+ * 不落盘;调用方用 {@link ensureChatWorkspace} 创建。
64
+ */
65
+ export function chatWorkspacePath(chatId, options) {
66
+ const id = sanitizeProjectDirName(chatId);
67
+ return path.join(appProjectsRoot(options), CHAT_WORKSPACES_DIR, id);
68
+ }
69
+ /** 确保无项目对话工作区存在,返回绝对路径。 */
70
+ export function ensureChatWorkspace(chatId, options) {
71
+ const folderPath = chatWorkspacePath(chatId, options);
72
+ ensureProjectHome(folderPath);
73
+ return folderPath;
74
+ }
@@ -34,7 +34,10 @@ import { getSidecarSupervisor } from '../llm/index.js';
34
34
  import { matchPackHttpRoute } from '../host/http-routes.js';
35
35
  import { getBrand } from '../brand.js';
36
36
  import { saveAttachmentFiles } from '../attachments.js';
37
+ import { selectNativeDirectory } from '../native-folder-dialog.js';
37
38
  import { getAuthProvider } from '../auth/index.js';
39
+ import { isHostRouteAllowed } from '../host-tools.js';
40
+ import { getResolvedHostTools } from '../host-tools-runtime.js';
38
41
  /** 与 router.handleStream 内部的路由正则保持一致——只有这些路径是流式。 */
39
42
  const STREAM_PATH_PATTERNS = [
40
43
  /^\/api\/v2\/chats\/[^/]+\/(send|run|agent)$/,
@@ -152,6 +155,10 @@ export function createBsServer(deps) {
152
155
  async function handleHost(req, res, pathname) {
153
156
  const body = asRecord(await readJsonBody(req));
154
157
  const method = req.method ?? 'GET';
158
+ if (!isHostRouteAllowed(pathname, getResolvedHostTools())) {
159
+ sendJson(res, 403, { detail: 'Host route is disabled for this product' });
160
+ return;
161
+ }
155
162
  if (method === 'GET' && pathname === '/host/info') {
156
163
  const brand = getBrand();
157
164
  sendJson(res, 200, {
@@ -250,6 +257,11 @@ export function createBsServer(deps) {
250
257
  return;
251
258
  }
252
259
  // ─── local(对齐 local:* IPC) ───
260
+ if (method === 'POST' && pathname === '/host/local/select-directory') {
261
+ const title = typeof body.title === 'string' ? body.title : undefined;
262
+ sendJson(res, 200, await selectNativeDirectory({ title }));
263
+ return;
264
+ }
253
265
  if (method === 'POST' && pathname === '/host/local/exec-shell') {
254
266
  const request = body;
255
267
  const viaTerminal = await maybeExecInTerminal(request);
@@ -324,6 +336,7 @@ export function createBsServer(deps) {
324
336
  sendJson(res, 200, await saveAttachmentFiles(chatId, files));
325
337
  return;
326
338
  }
339
+ log.warn(`[bs] ${method} ${pathname} -> 404`);
327
340
  sendJson(res, 404, { detail: `Unknown host endpoint: ${method} ${pathname}` });
328
341
  }
329
342
  /** 静态托管 + index.html 注入 BS 标记(前端据此切换 http-bridge)。 */
@@ -5,7 +5,7 @@ import type { SidecarReverseHandler } from './types.js';
5
5
  export interface HostSidecarDeps {
6
6
  store: ScopedStore;
7
7
  toolRouter: ToolRouter;
8
- /** W4-2 项目模式围栏:chatId → 项目根(无项目对话返回 null)。 */
8
+ /** W4-2 围栏:chatId → 项目家目录或无项目对话工作区。 */
9
9
  resolveProjectRoot: (chatId: string) => Promise<string | null>;
10
10
  /**
11
11
  * 项目模式之外额外放行的只读根(会话附件目录等)。文件写入仍只受
@@ -16,7 +16,7 @@ export interface ReverseToolDeps {
16
16
  }) => void;
17
17
  /**
18
18
  * 项目模式:按 chatId 解析项目根(绝对路径),无绑定/已删除返回 null。
19
- * main.ts 注入 LocalBackendRouter.resolveChatProject 的适配;未注入时
19
+ * main.ts 注入对话可写根(项目家目录或无项目对话工作区);未注入时
20
20
  * 项目围栏不生效(CLI/test 场景)。
21
21
  */
22
22
  resolveProjectRoot?: (chatId: string) => Promise<string | null>;
@@ -12,7 +12,7 @@ export interface ChatSessionRecord {
12
12
  title: string;
13
13
  userId: string;
14
14
  agentId: string | null;
15
- /** 项目模式:绑定的项目 id(ProjectRegistry)。null = 无项目对话(不沙箱)。 */
15
+ /** 项目模式:绑定的项目 id(ProjectRegistry)。null = 无项目对话(用对话工作区)。 */
16
16
  projectId: string | null;
17
17
  createdAt: string;
18
18
  updatedAt: string;
@@ -81,6 +81,21 @@ export declare function llmSettingsCarryExpiredBakedKey(s: LlmSettings): boolean
81
81
  export declare const OLLAMA_DEFAULTS: LlmSettings;
82
82
  export declare function sanitizeLlmProvider(raw: unknown): LlmProvider;
83
83
  export declare function sanitizeVendorId(raw: unknown): string | undefined;
84
+ export interface ProductLlmConfig {
85
+ provider?: string;
86
+ vendorId?: string;
87
+ model?: string;
88
+ baseUrl?: string;
89
+ apiKey?: string;
90
+ apiKeyEnv?: string;
91
+ temperature?: number;
92
+ maxTotalTokens?: number;
93
+ }
94
+ /**
95
+ * `settings.llm` 关掉后,产品必须声明 `llm.model`。密钥优先 product.apiKey,
96
+ * 其次 `apiKeyEnv` 指向的环境变量,再回落已存 key(升级前用户填过的)。
97
+ */
98
+ export declare function resolveLockedLlmSettings(value: unknown, env?: Record<string, string | undefined>, persisted?: LlmSettings | null): LlmSettings;
84
99
  /** Sidecar `agent.chat.stream` 的 provider 字段。 */
85
100
  export declare function sidecarWireProvider(provider: LlmProvider): string;
86
101
  export declare function usesOpenAiCompatExtras(provider: LlmProvider): boolean;
@@ -67,6 +67,34 @@ export function sanitizeVendorId(raw) {
67
67
  const id = raw.trim();
68
68
  return id || undefined;
69
69
  }
70
+ /**
71
+ * `settings.llm` 关掉后,产品必须声明 `llm.model`。密钥优先 product.apiKey,
72
+ * 其次 `apiKeyEnv` 指向的环境变量,再回落已存 key(升级前用户填过的)。
73
+ */
74
+ export function resolveLockedLlmSettings(value, env = {}, persisted = null) {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
76
+ throw new Error('[product-llm] settings.llm is off; product.json must declare llm');
77
+ }
78
+ const raw = value;
79
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
80
+ if (!model) {
81
+ throw new Error('[product-llm] llm.model is required when settings.llm is off');
82
+ }
83
+ const fromProduct = typeof raw.apiKey === 'string' && raw.apiKey.trim() ? raw.apiKey.trim() : undefined;
84
+ const envName = typeof raw.apiKeyEnv === 'string' ? raw.apiKeyEnv.trim() : '';
85
+ const fromEnv = envName ? env[envName]?.trim() || undefined : undefined;
86
+ const fromPersisted = persisted?.apiKey?.trim() || undefined;
87
+ return mergeLlmSettings({
88
+ provider: sanitizeLlmProvider(raw.provider ?? persisted?.provider ?? 'openai-compat'),
89
+ vendorId: sanitizeVendorId(raw.vendorId),
90
+ model,
91
+ baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl : persisted?.baseUrl,
92
+ apiKey: fromProduct || fromEnv || fromPersisted,
93
+ temperature: typeof raw.temperature === 'number' ? raw.temperature : undefined,
94
+ maxTotalTokens: typeof raw.maxTotalTokens === 'number' ? raw.maxTotalTokens : persisted?.maxTotalTokens,
95
+ execTimeoutSeconds: persisted?.execTimeoutSeconds,
96
+ });
97
+ }
70
98
  /** Sidecar `agent.chat.stream` 的 provider 字段。 */
71
99
  export function sidecarWireProvider(provider) {
72
100
  switch (provider) {
@@ -5,6 +5,8 @@ import { mcpExecutor } from './mcp-executor.js';
5
5
  import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
6
6
  import { maybeAutoInstallWrittenSkill } from './local-backend/skill-install.js';
7
7
  import { filterToolsByPolicy, isToolAllowed, } from './local-backend/agent-capability.js';
8
+ import { isHostToolCapabilityEnabled } from './host-tools.js';
9
+ import { getResolvedHostTools } from './host-tools-runtime.js';
8
10
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
9
11
  export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
10
12
  /**
@@ -608,7 +610,8 @@ export class ToolRouter {
608
610
  */
609
611
  listModelSchemas(policy) {
610
612
  const effective = policy ?? { mode: 'all', tools: [] };
611
- const all = filterToolsByPolicy(this.listSchemas(), effective);
613
+ const tools = getResolvedHostTools();
614
+ const all = filterToolsByPolicy(this.listSchemas(), effective).filter((s) => isHostToolCapabilityEnabled(s.name, tools));
612
615
  const hasDeferred = all.some(s => s.exposure === 'deferred');
613
616
  return all.filter(s => {
614
617
  if ((s.exposure ?? 'direct') !== 'direct')
@@ -630,6 +633,9 @@ export class ToolRouter {
630
633
  if (toolPolicy && !isToolAllowed(toolPolicy, call.name)) {
631
634
  throw new Error(`工具 ${call.name} 被当前智能体的工具策略拒绝(智能体管理 → 工具权限)`);
632
635
  }
636
+ if (!isHostToolCapabilityEnabled(call.name, getResolvedHostTools())) {
637
+ throw new Error(`工具 ${call.name} 未在本产品引入`);
638
+ }
633
639
  if (call.name.startsWith(MCP_DYNAMIC_TOOL_PREFIX)) {
634
640
  return await this.executeRegisteredMcpTool(call.name, args);
635
641
  }
@@ -883,7 +889,8 @@ export class ToolRouter {
883
889
  */
884
890
  executeToolSearch(args, policy) {
885
891
  const cap = resolveMaxResults(args.max_results);
886
- const deferred = filterToolsByPolicy(this.listSchemas().filter(s => s.exposure === 'deferred'), policy ?? { mode: 'all', tools: [] });
892
+ const tools = getResolvedHostTools();
893
+ const deferred = filterToolsByPolicy(this.listSchemas().filter(s => s.exposure === 'deferred'), policy ?? { mode: 'all', tools: [] }).filter((s) => isHostToolCapabilityEnabled(s.name, tools));
887
894
  const ranked = rankTools(deferred, String(args.query ?? ''));
888
895
  const matches = ranked.slice(0, cap).map(schema => ({
889
896
  name: schema.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
4
4
  "description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -15,6 +15,10 @@
15
15
  "type": "module",
16
16
  "main": "dist/runtime.js",
17
17
  "exports": {
18
+ "./host-tools": {
19
+ "types": "./dist/host-tools.d.ts",
20
+ "default": "./dist/host-tools.js"
21
+ },
18
22
  "./auth": {
19
23
  "types": "./dist/auth/index.d.ts",
20
24
  "default": "./dist/auth/index.js"
@@ -49,9 +53,9 @@
49
53
  "electron-store": "^8.1.0",
50
54
  "he": "^1.2.0",
51
55
  "node-pty": "^1.1.0",
52
- "@steerable/agent-harness": "0.6.31",
53
- "@steerable/pack-sdk": "0.6.31",
54
- "@steerable/agent-protocol": "0.6.31"
56
+ "@steerable/agent-harness": "0.6.33",
57
+ "@steerable/pack-sdk": "0.6.33",
58
+ "@steerable/agent-protocol": "0.6.33"
55
59
  },
56
60
  "devDependencies": {
57
61
  "@types/better-sqlite3": "^7.6.13",