@steerable/agent-shell 0.6.32 → 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.
@@ -31,6 +31,7 @@ import { getChatAttachmentsDir } from '../attachments.js';
31
31
  import { ensureChatWorkspace } from '../project-home.js';
32
32
  import { recordInsightTurn } from '../insights/record.js';
33
33
  import { llmService } from '../llm/index.js';
34
+ import { isProductLlmLocked, resolveRuntimeLlmSettings } from '../host-tools-runtime.js';
34
35
  import { getPackAssemblies, } from './pack-assembly.js';
35
36
  export async function createHostRuntime(options) {
36
37
  const { broadcast, hasWindow, onLog } = options;
@@ -65,15 +66,14 @@ export async function createHostRuntime(options) {
65
66
  // 生效,这里负责进程重启后的恢复)。
66
67
  {
67
68
  const persistedLlmSettings = await defaultStore.getLlmSettings();
68
- setDefaultExecTimeoutMs(persistedLlmSettings?.execTimeoutSeconds
69
- ? 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
70
75
  : null);
71
- llmService.initialize(persistedLlmSettings ?? {
72
- provider: 'ollama',
73
- model: 'llama3.1:8b',
74
- baseUrl: 'http://127.0.0.1:11434',
75
- temperature: 0.3,
76
- }, {
76
+ llmService.initialize(runtimeLlmSettings, {
77
77
  setLlmSettings: (settings) => defaultStore.setLlmSettings(settings),
78
78
  });
79
79
  }
@@ -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
+ }
@@ -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, ensureChatWorkspace, 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),
@@ -2020,7 +2037,7 @@ export class LocalBackendRouter {
2020
2037
  }
2021
2038
  // Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
2022
2039
  // 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
2023
- const chatMode = payload.mode === 'plan' ? 'plan' : 'agent';
2040
+ const chatMode = resolveTurnChatMode(payload.mode);
2024
2041
  // 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
2025
2042
  // 策略决定工具列表,工具列表又决定技能的触发条件。
2026
2043
  const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
@@ -2910,19 +2927,13 @@ export class LocalBackendRouter {
2910
2927
  // 工作区)。
2911
2928
  ...collectPackExecWritableRoots(chatId),
2912
2929
  workspaceRoot,
2913
- ], { policy: parseExecPolicy(payload.execPolicy) });
2930
+ ], { policy: clampExecPolicy(parseExecPolicy(payload.execPolicy), getResolvedHostTools()) });
2914
2931
  // W4-1: approval algebra, host mode — every tool call is gated by the
2915
2932
  // Electron approval UI over the reverse channel. The durable scope
2916
2933
  // (allow_always / deny_always) persists per tool category under
2917
- // ~/.steerable (writable inside the layer-1 sandbox). STEERABLE_APPROVAL=0
2918
- // restores the legacy ungated behavior.
2919
- const approval = process.env.STEERABLE_APPROVAL === '0'
2920
- ? undefined
2921
- : {
2922
- mode: 'host',
2923
- timeoutMs: 120_000,
2924
- storePath: path.join(os.homedir(), '.steerable', 'approvals.json'),
2925
- };
2934
+ // ~/.steerable (writable inside the layer-1 sandbox). 产品
2935
+ // approval:"off" 或 STEERABLE_APPROVAL=0 本轮不挂审批。
2936
+ const approval = resolveTurnApproval();
2926
2937
  let assistantText = '';
2927
2938
  const executedActions = [];
2928
2939
  const timeline = [];
@@ -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
  /** 任务回合的系统提示词:自包含、汇报结果、不假装执行。 */
@@ -279,13 +279,7 @@ export class TaskService {
279
279
  ? await this.deps.resolveChatWorkspaceRoot(chatId)
280
280
  : null);
281
281
  const execSandbox = buildExecSandbox(fenceRoot ? [fenceRoot] : []);
282
- const approval = process.env.STEERABLE_APPROVAL === '0'
283
- ? undefined
284
- : {
285
- mode: 'host',
286
- timeoutMs: 120_000,
287
- storePath: path.join(os.homedir(), '.steerable', 'approvals.json'),
288
- };
282
+ const approval = resolveTurnApproval();
289
283
  // depth-1:任务回合没有 task_run(不能再派生任务),其余工具面与
290
284
  // 普通回合一致;task_status / task_result 保留(任务可以自查/互查)。
291
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();
@@ -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
+ }
@@ -36,6 +36,8 @@ import { getBrand } from '../brand.js';
36
36
  import { saveAttachmentFiles } from '../attachments.js';
37
37
  import { selectNativeDirectory } from '../native-folder-dialog.js';
38
38
  import { getAuthProvider } from '../auth/index.js';
39
+ import { isHostRouteAllowed } from '../host-tools.js';
40
+ import { getResolvedHostTools } from '../host-tools-runtime.js';
39
41
  /** 与 router.handleStream 内部的路由正则保持一致——只有这些路径是流式。 */
40
42
  const STREAM_PATH_PATTERNS = [
41
43
  /^\/api\/v2\/chats\/[^/]+\/(send|run|agent)$/,
@@ -153,6 +155,10 @@ export function createBsServer(deps) {
153
155
  async function handleHost(req, res, pathname) {
154
156
  const body = asRecord(await readJsonBody(req));
155
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
+ }
156
162
  if (method === 'GET' && pathname === '/host/info') {
157
163
  const brand = getBrand();
158
164
  sendJson(res, 200, {
@@ -330,6 +336,7 @@ export function createBsServer(deps) {
330
336
  sendJson(res, 200, await saveAttachmentFiles(chatId, files));
331
337
  return;
332
338
  }
339
+ log.warn(`[bs] ${method} ${pathname} -> 404`);
333
340
  sendJson(res, 404, { detail: `Unknown host endpoint: ${method} ${pathname}` });
334
341
  }
335
342
  /** 静态托管 + index.html 注入 BS 标记(前端据此切换 http-bridge)。 */
@@ -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.32",
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-protocol": "0.6.32",
53
- "@steerable/pack-sdk": "0.6.32",
54
- "@steerable/agent-harness": "0.6.32"
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",