@steerable/agent-shell 0.6.52 → 0.6.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/brand.js +1 -1
  2. package/dist/host/pack-assembly.d.ts +6 -16
  3. package/dist/host/pack-assembly.js +3 -4
  4. package/dist/host/runtime.js +2 -0
  5. package/dist/i18n.d.ts +23 -0
  6. package/dist/i18n.js +87 -0
  7. package/dist/image-attachment.d.ts +31 -4
  8. package/dist/image-attachment.js +13 -18
  9. package/dist/json-store.js +3 -6
  10. package/dist/local-backend/portable-bundle.d.ts +16 -2
  11. package/dist/local-backend/portable-bundle.js +29 -16
  12. package/dist/local-backend/portable-service.js +25 -11
  13. package/dist/local-backend/router.js +56 -17
  14. package/dist/local-backend/skills/10-goal/SKILL.md +1 -1
  15. package/dist/local-backend/skills/11-loop/SKILL.md +1 -1
  16. package/dist/local-backend/skills/12-create-skill/SKILL.md +2 -2
  17. package/dist/local-executor.d.ts +22 -0
  18. package/dist/local-executor.js +118 -1
  19. package/dist/local-script-registry.js +2 -2
  20. package/dist/mcp-executor.d.ts +42 -10
  21. package/dist/mcp-executor.js +248 -41
  22. package/dist/mcp-server-registry.d.ts +59 -11
  23. package/dist/mcp-server-registry.js +193 -28
  24. package/dist/product-config.d.ts +24 -14
  25. package/dist/project-registry.d.ts +3 -3
  26. package/dist/project-registry.js +2 -2
  27. package/dist/runtime.d.ts +8 -54
  28. package/dist/runtime.js +15 -129
  29. package/dist/scenario/pack.d.ts +1 -1
  30. package/dist/scenario/registry.d.ts +4 -5
  31. package/dist/scenario/registry.js +2 -10
  32. package/dist/server/start.js +1 -2
  33. package/dist/shell-exec.d.ts +62 -0
  34. package/dist/shell-exec.js +147 -0
  35. package/dist/shell-process.d.ts +3 -0
  36. package/dist/shell-process.js +137 -0
  37. package/dist/sidecar/boot.js +4 -0
  38. package/dist/sidecar/reverse-spawn.js +6 -15
  39. package/dist/sidecar/supervisor.d.ts +0 -2
  40. package/dist/sidecar/supervisor.js +7 -30
  41. package/dist/storage/index.js +1 -1
  42. package/dist/storage/write-lease-error.d.ts +2 -11
  43. package/dist/storage/write-lease-error.js +3 -8
  44. package/dist/tool-router.js +21 -6
  45. package/package.json +7 -14
  46. package/dist/app-update-electron.d.ts +0 -5
  47. package/dist/app-update-electron.js +0 -66
  48. package/dist/app-update.d.ts +0 -92
  49. package/dist/app-update.js +0 -213
  50. package/dist/host/ipc.d.ts +0 -11
  51. package/dist/host/ipc.js +0 -20
  52. package/dist/main.d.ts +0 -1
  53. package/dist/main.js +0 -967
  54. package/dist/preload-default.d.ts +0 -1
  55. package/dist/preload-default.js +0 -9
  56. package/dist/preload.cjs +0 -425
  57. package/dist/preload.d.ts +0 -20
  58. package/dist/preload.js +0 -442
  59. package/dist/single-instance.d.ts +0 -11
  60. package/dist/single-instance.js +0 -21
package/dist/runtime.js CHANGED
@@ -1,56 +1,24 @@
1
1
  /**
2
- * 宿主运行时抽象:Electron 主进程与 BS 独立 Node server 共用的环境探测。
2
+ * 宿主运行时抽象:Tauri 监督的 Node 宿主与 BS 独立 Node server 共用的环境探测。
3
3
  *
4
- * CS 模式(Electron)下路径来自 `app.getPath('userData')`(按 productName
5
- * 分目录);BS 模式(`node dist/server/index.js`)没有 Electron,落到
6
- * `DEEPPATH_USER_DATA_DIR` 或产品注入的 `~/<dataDirName>`(3.1)。两种模式共用
7
- * 同一份 SQLite / JSON 存储代码,差异只在这一层。
4
+ * 数据目录优先取 `DEEPPATH_USER_DATA_DIR`(Tauri 宿主总是注入),否则落到
5
+ * 产品注入的 `~/<dataDirName>`(3.1)。两种模式共用同一份 SQLite / JSON
6
+ * 存储代码,差异只在这一层。
8
7
  *
9
8
  * 本模块只允许依赖 node 内置模块(与 brand.ts 同约束):storage 会被
10
- * vitest 直接 import,不能碰 electron 包。
9
+ * vitest 直接 import。
11
10
  */
12
11
  import { spawn } from 'node:child_process';
13
12
  import fs from 'node:fs';
14
- import { createRequire } from 'node:module';
15
13
  import os from 'node:os';
16
14
  import path from 'node:path';
17
15
  import { fileURLToPath } from 'node:url';
18
16
  import { getProductConfig } from './product-config.js';
19
- /** Electron 主进程里 `process.versions.electron` 有值;普通 Node(BS server、vitest)没有。 */
20
- export function isElectronRuntime() {
21
- return Boolean(process.versions.electron);
22
- }
23
- let cachedApp;
24
- /**
25
- * 同步拿 Electron app。普通 Node 下 `require('electron')` 返回二进制路径
26
- * 字符串(没有 app),必须拒绝;Electron 主进程里才是真正的 API 对象。
27
- */
28
- function tryElectronApp() {
29
- if (cachedApp !== undefined)
30
- return cachedApp;
31
- if (!isElectronRuntime()) {
32
- cachedApp = null;
33
- return null;
34
- }
35
- try {
36
- const require = createRequire(import.meta.url);
37
- const mod = require('electron');
38
- cachedApp = mod && typeof mod.app?.getPath === 'function' ? mod.app : null;
39
- }
40
- catch {
41
- cachedApp = null;
42
- }
43
- return cachedApp;
44
- }
45
17
  /** 应用数据目录(SQLite、JSON store、用户技能目录的根)。 */
46
18
  export function getUserDataDir() {
47
19
  if (process.env.DEEPPATH_USER_DATA_DIR)
48
20
  return process.env.DEEPPATH_USER_DATA_DIR;
49
- const app = tryElectronApp();
50
- if (app)
51
- return app.getPath('userData');
52
- // BS:目录名是产品注入配置(3.1,product.json dataDirName),与 Electron
53
- // 下 productName 分目录的共存语义对齐;中性 shell 缺省 .agent-shell。
21
+ // 目录名是产品注入配置(3.1,product.json dataDirName);中性 shell 缺省 .agent-shell。
54
22
  const dirName = getProductConfig().dataDirName ?? '.agent-shell';
55
23
  return path.join(os.homedir(), dirName);
56
24
  }
@@ -62,15 +30,6 @@ export function getUserDataDir() {
62
30
  export function getDocumentsDir() {
63
31
  if (process.env.STEERABLE_DOCUMENTS_DIR)
64
32
  return process.env.STEERABLE_DOCUMENTS_DIR;
65
- const app = tryElectronApp();
66
- if (app) {
67
- try {
68
- return app.getPath('documents');
69
- }
70
- catch {
71
- // 个别环境 getPath('documents') 不可用,回落到 ~/Documents。
72
- }
73
- }
74
33
  return path.join(os.homedir(), 'Documents');
75
34
  }
76
35
  /**
@@ -91,9 +50,6 @@ export function setAppRootDir(dir) {
91
50
  appRootOverride = dir;
92
51
  }
93
52
  export function getAppRootDir() {
94
- const app = tryElectronApp();
95
- if (app)
96
- return app.getAppPath();
97
53
  if (appRootOverride)
98
54
  return appRootOverride;
99
55
  // 单测/脚本平面:从 cwd 向上找含 package.json 的目录。
@@ -107,37 +63,12 @@ export function getAppRootDir() {
107
63
  }
108
64
  }
109
65
  /**
110
- * preload bundle 路径(3.4 修复):shell main.ts 创建窗口时需要
111
- * preload.cjs——shell 自带默认面(dist/preload.cjs,buildPreloadApi()
112
- * 无包贡献),但带 invoke 型包贡献的产品由产品构建把组合后的
113
- * preload.cjs 出在产品 main.js 旁,产品入口在动态 import
114
- * shell main 之前经本函数注入。未注入时回退 shell 自带默认。
115
- */
116
- let preloadPathOverride = null;
117
- /**
118
- * 注入 preload bundle 绝对路径(产品组装根/入口调用)。重复注入不同值
119
- * 抛错(组装期笔误,fail fast——与 setAppRootDir 同语义)。
120
- */
121
- export function setPreloadPath(file) {
122
- if (preloadPathOverride && preloadPathOverride !== file) {
123
- throw new Error('[runtime] preload path already set');
124
- }
125
- preloadPathOverride = file;
126
- }
127
- /**
128
- * 解析窗口应加载的 preload bundle:产品注入优先,其次 shell 自带默认
129
- * (shellDir 一般为 shell main.js 所在目录)。
130
- */
131
- export function getPreloadPath(shellDir) {
132
- return preloadPathOverride ?? path.join(shellDir, 'preload.cjs');
133
- }
134
- /**
135
- * 产品 web 产物目录(2.3):产品入口(products/<id>/{main,server}.ts)
136
- * 用本函数把 DEEPPATH_WEB_DIST 注入环境,shell 的 main/server 据此定位
137
- * web dist。双平面探测:源码平面入口在 products/<id>/(web dist 在同级
138
- * web/dist);编译平面入口在 products/<id>/dist/products/<id>/(web dist
139
- * 在仓库根的 products/<id>/web/dist)。两处都没有返回 null(打包后的
140
- * CS 走 app.getAppPath()/web-dist 约定,不经本函数)。
66
+ * 产品 web 产物目录(2.3):产品入口(products/<id>/server.ts)用本函数
67
+ * 把 DEEPPATH_WEB_DIST 注入环境,shell server 据此定位 web dist。双平面
68
+ * 探测:源码平面入口在 products/<id>/(web dist 在同级 web/dist);编译
69
+ * 平面入口在 products/<id>/dist/products/<id>/(web dist 在仓库根的
70
+ * products/<id>/web/dist)。两处都没有返回 null(打包后的 Tauri 宿主直接
71
+ * 注入 DEEPPATH_WEB_DIST,不经本函数)。
141
72
  */
142
73
  export function resolveProductWebDist(entryUrl) {
143
74
  const here = path.dirname(fileURLToPath(entryUrl));
@@ -155,22 +86,13 @@ export function resolveProductWebDist(entryUrl) {
155
86
  }
156
87
  return null;
157
88
  }
158
- /** 用系统默认应用打开本地路径(Electron `shell.openPath` 的跨宿主等价物)。 */
89
+ /** 用系统默认应用打开本地路径。返回空串表示成功,否则是错误消息。 */
159
90
  export async function shellOpenPath(target) {
160
- if (tryElectronApp()) {
161
- const { shell } = (await import('electron'));
162
- return shell.openPath(target);
163
- }
164
91
  const [cmd, args] = openCommand(target, false);
165
92
  return spawnDetached(cmd, args);
166
93
  }
167
- /** 用系统浏览器打开 URL(Electron `shell.openExternal` 的跨宿主等价物)。 */
94
+ /** 用系统浏览器打开 URL。 */
168
95
  export async function shellOpenExternal(url) {
169
- if (tryElectronApp()) {
170
- const { shell } = (await import('electron'));
171
- await shell.openExternal(url);
172
- return;
173
- }
174
96
  const [cmd, args] = openCommand(url, true);
175
97
  const error = await spawnDetached(cmd, args);
176
98
  if (error)
@@ -186,7 +108,7 @@ function openCommand(target, isUrl) {
186
108
  }
187
109
  return ['xdg-open', [target]];
188
110
  }
189
- /** 返回空串表示成功(对齐 shell.openPath 的约定),否则是错误消息。 */
111
+ /** 返回空串表示成功,否则是错误消息。 */
190
112
  function spawnDetached(cmd, args) {
191
113
  return new Promise((resolve) => {
192
114
  try {
@@ -202,39 +124,3 @@ function spawnDetached(cmd, args) {
202
124
  }
203
125
  });
204
126
  }
205
- /**
206
- * 注册应用退出钩子(sidecar 的兜底清理)。BS 下没有 app 生命周期——
207
- * 宿主退出由 server 的 SIGINT/SIGTERM 钩子统一负责,这里 no-op。
208
- */
209
- export function onAppWillQuit(listener) {
210
- try {
211
- tryElectronApp()?.on?.('will-quit', listener);
212
- }
213
- catch {
214
- /* 非 Electron 宿主 */
215
- }
216
- }
217
- export function offAppWillQuit(listener) {
218
- try {
219
- tryElectronApp()?.removeListener?.('will-quit', listener);
220
- }
221
- catch {
222
- /* 非 Electron 宿主 */
223
- }
224
- }
225
- /**
226
- * Electron `nativeImage`(图片解码/缩放)。BS / 单测等无 Electron 环境返回
227
- * null——调用方按"不支持图片解码"降级(image-attachment 已有该分支)。
228
- */
229
- export function getNativeImage() {
230
- if (!isElectronRuntime())
231
- return null;
232
- try {
233
- const require = createRequire(import.meta.url);
234
- const mod = require('electron');
235
- return typeof mod.nativeImage?.createFromPath === 'function' ? mod.nativeImage : null;
236
- }
237
- catch {
238
- return null;
239
- }
240
- }
@@ -5,4 +5,4 @@
5
5
  *
6
6
  * 契约全文与设计约束见 packages/pack-sdk/types/index.d.ts 头注释。
7
7
  */
8
- export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, IpcContribution, HttpRouteContribution, HttpRouteRequest, MainContribution, PreloadContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
8
+ export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, HttpRouteContribution, HttpRouteRequest, MainContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
@@ -2,11 +2,10 @@
2
2
  * 场景包注册表:宿主装配期的单一入口。
3
3
  *
4
4
  * 产品的 composition root 在启动早期把选中的包 register 进来;宿主各
5
- * 子系统(ToolRouter / storage / copy-skills / IPC / 渲染层槽位)从这里
5
+ * 子系统(ToolRouter / storage / copy-skills / 渲染层槽位)从这里
6
6
  * 按槽位读取贡献,而不是各自硬编码 import 场景代码。
7
7
  *
8
- * 注册期即校验(fail loud):重复 id、IPC 通道不带 '<packId>:' 前缀、
9
- * preload 命名空间与包 id 不一致,都在 register 时抛错,而不是等到
8
+ * 注册期即校验(fail loud):重复 id 在 register 时抛错,而不是等到
10
9
  * 运行时才暴露。
11
10
  */
12
11
  import type { ScenarioId, ScenarioPack } from './pack.js';
@@ -18,7 +17,7 @@ export declare class ScenarioRegistry {
18
17
  list(): readonly ScenarioPack[];
19
18
  }
20
19
  /**
21
- * 进程级默认注册表。composition root(main.ts / server/index.ts /
22
- * 渲染层入口)在最早阶段填充;各子系统消费同一个实例。
20
+ * 进程级默认注册表。composition root(server/index.ts / 渲染层入口)
21
+ * 在最早阶段填充;各子系统消费同一个实例。
23
22
  */
24
23
  export declare const scenarioRegistry: ScenarioRegistry;
@@ -4,14 +4,6 @@ export class ScenarioRegistry {
4
4
  if (this.packs.has(pack.id)) {
5
5
  throw new Error(`[scenario] duplicate pack id: ${pack.id}`);
6
6
  }
7
- for (const ipc of pack.main?.ipc ?? []) {
8
- if (!ipc.channel.startsWith(`${pack.id}:`)) {
9
- throw new Error(`[scenario] pack ${pack.id} ipc channel "${ipc.channel}" must start with "${pack.id}:"`);
10
- }
11
- }
12
- if (pack.preload && pack.preload.namespace !== pack.id) {
13
- throw new Error(`[scenario] pack ${pack.id} preload namespace "${pack.preload.namespace}" must equal pack id`);
14
- }
15
7
  this.packs.set(pack.id, pack);
16
8
  }
17
9
  get(id) {
@@ -25,7 +17,7 @@ export class ScenarioRegistry {
25
17
  }
26
18
  }
27
19
  /**
28
- * 进程级默认注册表。composition root(main.ts / server/index.ts /
29
- * 渲染层入口)在最早阶段填充;各子系统消费同一个实例。
20
+ * 进程级默认注册表。composition root(server/index.ts / 渲染层入口)
21
+ * 在最早阶段填充;各子系统消费同一个实例。
30
22
  */
31
23
  export const scenarioRegistry = new ScenarioRegistry();
@@ -13,8 +13,7 @@ import { getUserDataDir } from '../runtime.js';
13
13
  import { createBsServer, } from './http-server.js';
14
14
  import { SseBus } from './sse-bus.js';
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- // Match the Electron entry's dotenv precedence: local file first, existing
17
- // process variables always win.
16
+ // dotenv precedence: local file first, existing process variables always win.
18
17
  for (const file of ['.env.local', '.env']) {
19
18
  const fullPath = path.join(path.resolve(__dirname, '..', '..'), file);
20
19
  if (existsSync(fullPath))
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shell 执行规划:前缀策略,以及本地 / SSH 的启动参数。
3
+ * 这里不启动进程。多条前缀同时命中时,取更严的决定:
4
+ * allow < prompt < forbidden。
5
+ */
6
+ export type ExecDecisionName = 'allow' | 'prompt' | 'forbidden';
7
+ export interface PrefixRule {
8
+ argv: string[];
9
+ decision: ExecDecisionName;
10
+ justification?: string;
11
+ }
12
+ export interface ExecPolicy {
13
+ rules: PrefixRule[];
14
+ }
15
+ export interface ExecEvaluation {
16
+ decision: ExecDecisionName;
17
+ command: string[];
18
+ rule?: PrefixRule;
19
+ }
20
+ export interface SshEndpoint {
21
+ kind: 'ssh';
22
+ destination: string;
23
+ remoteCwd?: string;
24
+ }
25
+ export type ShellEndpoint = {
26
+ kind: 'local';
27
+ } | SshEndpoint;
28
+ export interface ShellRunSpec {
29
+ file: string;
30
+ args: string[];
31
+ cwd?: string;
32
+ env?: Record<string, string>;
33
+ pty: boolean;
34
+ transport: 'local' | 'ssh';
35
+ }
36
+ export interface ShellRunLimits {
37
+ timeoutMs: number;
38
+ maxOutputBytes: number;
39
+ gui: boolean;
40
+ }
41
+ export interface ShellRunOutcome {
42
+ exitCode: number;
43
+ stdout: string;
44
+ stderr: string;
45
+ truncated?: boolean;
46
+ timedOut?: boolean;
47
+ stillRunning?: boolean;
48
+ error?: string;
49
+ }
50
+ export interface ShellProcessBackend {
51
+ run(spec: ShellRunSpec, limits: ShellRunLimits): Promise<ShellRunOutcome>;
52
+ }
53
+ export declare function splitShellScript(script: string): string[][];
54
+ export declare function evaluateExecPolicy(script: string, policy: ExecPolicy): ExecEvaluation;
55
+ export declare function planSshArgv(input: {
56
+ command: string;
57
+ cwd?: string;
58
+ pty: boolean;
59
+ shell?: 'powershell';
60
+ endpoint: SshEndpoint;
61
+ }): ShellRunSpec;
62
+ export declare function shellQuote(value: string): string;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shell 执行规划:前缀策略,以及本地 / SSH 的启动参数。
3
+ * 这里不启动进程。多条前缀同时命中时,取更严的决定:
4
+ * allow < prompt < forbidden。
5
+ */
6
+ const DECISION_RANK = {
7
+ allow: 0,
8
+ prompt: 1,
9
+ forbidden: 2,
10
+ };
11
+ export function splitShellScript(script) {
12
+ return splitSegments(script).map(tokenize).filter((argv) => argv.length > 0);
13
+ }
14
+ export function evaluateExecPolicy(script, policy) {
15
+ const commands = splitShellScript(script);
16
+ let best = { decision: 'allow', command: commands[0] ?? [] };
17
+ for (const command of commands) {
18
+ for (const rule of policy.rules) {
19
+ if (rule.argv.length === 0 || !isPrefix(rule.argv, command))
20
+ continue;
21
+ const stricter = DECISION_RANK[rule.decision] > DECISION_RANK[best.decision];
22
+ const sameDecisionLonger = rule.decision === best.decision && rule.argv.length > (best.rule?.argv.length ?? 0);
23
+ if (stricter || sameDecisionLonger) {
24
+ best = { decision: rule.decision, command, rule };
25
+ }
26
+ }
27
+ }
28
+ return best;
29
+ }
30
+ export function planSshArgv(input) {
31
+ const destination = input.endpoint.destination.trim();
32
+ if (!destination) {
33
+ throw new Error('ssh destination is required');
34
+ }
35
+ const cwd = input.cwd ?? input.endpoint.remoteCwd;
36
+ const script = remoteScript(input.command, cwd, input.shell);
37
+ const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15'];
38
+ if (input.pty)
39
+ args.push('-tt');
40
+ args.push(destination, '--');
41
+ if (input.shell === 'powershell') {
42
+ args.push('pwsh', '-NoProfile', '-NonInteractive', '-Command', script);
43
+ }
44
+ else {
45
+ args.push('bash', '-lc', script);
46
+ }
47
+ return { file: 'ssh', args, pty: input.pty, transport: 'ssh' };
48
+ }
49
+ export function shellQuote(value) {
50
+ return `'${value.replace(/'/g, `'\\''`)}'`;
51
+ }
52
+ function remoteScript(command, cwd, shell) {
53
+ if (!cwd)
54
+ return command;
55
+ if (shell === 'powershell')
56
+ return `Set-Location ${shellQuote(cwd)}; ${command}`;
57
+ return `cd ${shellQuote(cwd)} && ${command}`;
58
+ }
59
+ function isPrefix(prefix, argv) {
60
+ if (prefix.length > argv.length)
61
+ return false;
62
+ return prefix.every((token, index) => token === argv[index]);
63
+ }
64
+ function splitSegments(script) {
65
+ const segments = [];
66
+ let current = '';
67
+ let quote = null;
68
+ for (let i = 0; i < script.length; i += 1) {
69
+ const ch = script[i];
70
+ if (quote) {
71
+ current += ch;
72
+ if (ch === '\\' && quote === '"' && i + 1 < script.length) {
73
+ current += script[i + 1];
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (ch === quote)
78
+ quote = null;
79
+ continue;
80
+ }
81
+ if (ch === '\\' && i + 1 < script.length) {
82
+ current += ch + script[i + 1];
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (ch === '"' || ch === "'") {
87
+ quote = ch;
88
+ current += ch;
89
+ continue;
90
+ }
91
+ const two = script.slice(i, i + 2);
92
+ if (two === '&&' || two === '||' || two === '|&') {
93
+ segments.push(current);
94
+ current = '';
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (ch === ';' || ch === '|' || ch === '\n') {
99
+ segments.push(current);
100
+ current = '';
101
+ continue;
102
+ }
103
+ current += ch;
104
+ }
105
+ segments.push(current);
106
+ return segments.map((segment) => segment.trim()).filter((segment) => segment.length > 0);
107
+ }
108
+ function tokenize(segment) {
109
+ const tokens = [];
110
+ let current = '';
111
+ let quote = null;
112
+ for (let i = 0; i < segment.length; i += 1) {
113
+ const ch = segment[i];
114
+ if (quote) {
115
+ if (ch === '\\' && quote === '"' && i + 1 < segment.length) {
116
+ current += segment[i + 1];
117
+ i += 1;
118
+ continue;
119
+ }
120
+ if (ch === quote) {
121
+ quote = null;
122
+ continue;
123
+ }
124
+ current += ch;
125
+ continue;
126
+ }
127
+ if (ch === '\\' && i + 1 < segment.length) {
128
+ current += segment[i + 1];
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (ch === '"' || ch === "'") {
133
+ quote = ch;
134
+ continue;
135
+ }
136
+ if (ch === ' ' || ch === '\t') {
137
+ if (current)
138
+ tokens.push(current);
139
+ current = '';
140
+ continue;
141
+ }
142
+ current += ch;
143
+ }
144
+ if (current)
145
+ tokens.push(current);
146
+ return tokens;
147
+ }
@@ -0,0 +1,3 @@
1
+ import type { ShellProcessBackend } from './shell-exec.js';
2
+ export declare function stripAnsi(input: string): string;
3
+ export declare const nodeShellBackend: ShellProcessBackend;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 本地进程启动:管道用 child_process,PTY 用 node-pty。
3
+ * SSH 也走这里,argv 由 shell-exec 规划好,本文件不再解释命令文本。
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import * as pty from 'node-pty';
7
+ // eslint-disable-next-line no-control-regex
8
+ const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
9
+ export function stripAnsi(input) {
10
+ return input.replace(ANSI_RE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
11
+ }
12
+ export const nodeShellBackend = {
13
+ run(spec, limits) {
14
+ return spec.pty ? runPty(spec, limits) : runPipe(spec, limits);
15
+ },
16
+ };
17
+ function runPipe(spec, limits) {
18
+ return new Promise((resolve) => {
19
+ const stdoutChunks = [];
20
+ const stderrChunks = [];
21
+ let stdoutBytes = 0;
22
+ let stderrBytes = 0;
23
+ let truncated = false;
24
+ let settled = false;
25
+ const child = spawn(spec.file, spec.args, {
26
+ cwd: spec.cwd,
27
+ env: spec.env,
28
+ windowsHide: true,
29
+ });
30
+ const finish = (outcome) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ clearTimeout(timer);
35
+ resolve(outcome);
36
+ };
37
+ const take = (chunks, bytes, chunk) => {
38
+ if (bytes >= limits.maxOutputBytes) {
39
+ truncated = true;
40
+ return bytes;
41
+ }
42
+ const remain = limits.maxOutputBytes - bytes;
43
+ const safe = chunk.length > remain ? chunk.subarray(0, remain) : chunk;
44
+ chunks.push(safe);
45
+ if (safe.length < chunk.length)
46
+ truncated = true;
47
+ return bytes + safe.length;
48
+ };
49
+ child.stdout?.on('data', (chunk) => {
50
+ stdoutBytes = take(stdoutChunks, stdoutBytes, chunk);
51
+ });
52
+ child.stderr?.on('data', (chunk) => {
53
+ stderrBytes = take(stderrChunks, stderrBytes, chunk);
54
+ });
55
+ const stdout = () => Buffer.concat(stdoutChunks).toString('utf-8');
56
+ const stderr = () => Buffer.concat(stderrChunks).toString('utf-8');
57
+ const timer = setTimeout(() => {
58
+ if (!limits.gui)
59
+ child.kill();
60
+ finish({
61
+ exitCode: -1,
62
+ stdout: stdout(),
63
+ stderr: stderr(),
64
+ truncated,
65
+ timedOut: true,
66
+ stillRunning: limits.gui,
67
+ error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
68
+ });
69
+ }, limits.timeoutMs);
70
+ child.on('error', (err) => {
71
+ finish({ exitCode: -1, stdout: stdout(), stderr: stderr(), truncated, error: err.message });
72
+ });
73
+ child.on('close', (exitCode) => {
74
+ finish({
75
+ exitCode: exitCode ?? -1,
76
+ stdout: stdout(),
77
+ stderr: stderr(),
78
+ truncated,
79
+ });
80
+ });
81
+ });
82
+ }
83
+ function runPty(spec, limits) {
84
+ return new Promise((resolve) => {
85
+ let output = '';
86
+ let truncated = false;
87
+ let settled = false;
88
+ const proc = pty.spawn(spec.file, spec.args, {
89
+ name: 'xterm-256color',
90
+ cols: 120,
91
+ rows: 30,
92
+ cwd: spec.cwd,
93
+ env: { ...spec.env, TERM: 'xterm-256color' },
94
+ });
95
+ const finish = (outcome) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ resolve(outcome);
101
+ };
102
+ proc.onData((chunk) => {
103
+ const next = output + chunk;
104
+ if (Buffer.byteLength(next) > limits.maxOutputBytes) {
105
+ truncated = true;
106
+ output = trimToBytes(next, limits.maxOutputBytes);
107
+ return;
108
+ }
109
+ output = next;
110
+ });
111
+ const timer = setTimeout(() => {
112
+ if (!limits.gui)
113
+ proc.kill();
114
+ finish({
115
+ exitCode: -1,
116
+ stdout: stripAnsi(output),
117
+ stderr: '',
118
+ truncated,
119
+ timedOut: true,
120
+ stillRunning: limits.gui,
121
+ error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
122
+ });
123
+ }, limits.timeoutMs);
124
+ proc.onExit(({ exitCode }) => {
125
+ finish({
126
+ exitCode: exitCode ?? -1,
127
+ stdout: stripAnsi(output),
128
+ stderr: '',
129
+ truncated,
130
+ });
131
+ });
132
+ });
133
+ }
134
+ function trimToBytes(value, maxBytes) {
135
+ const buffer = Buffer.from(value);
136
+ return buffer.subarray(0, maxBytes).toString('utf-8');
137
+ }
@@ -20,6 +20,7 @@ import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, d
20
20
  import { WEB_TOOL_RPC_TIMEOUT_MS, webToolsEnabled } from '../tool-router.js';
21
21
  import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
22
22
  import { executeHostedWebSearch } from '../hosted-web-search.js';
23
+ import { getProductConfig } from '../product-config.js';
23
24
  let egressProxyRef = null;
24
25
  let supervisorRef = null;
25
26
  let sidecarGeneration = 0;
@@ -121,6 +122,9 @@ async function startEgressProxyIfEnabled(store) {
121
122
  ? {
122
123
  STEERABLE_EGRESS_CONTROL_PORT: String(egressProxyRef.controlPort),
123
124
  [plan.control.tokenEnv]: plan.control.tokenValue,
125
+ ...(getProductConfig().egressApproval === 'off'
126
+ ? { STEERABLE_EGRESS_APPROVAL: 'off' }
127
+ : {}),
124
128
  }
125
129
  : {};
126
130
  return {
@@ -33,21 +33,12 @@ export function resolveWinSpawnHelperPath() {
33
33
  if (override !== undefined) {
34
34
  return override && existsSync(override) ? override : null;
35
35
  }
36
- const electron = process;
37
- const resourcesPath = electron.resourcesPath;
38
- const candidates = [];
39
- if (resourcesPath) {
40
- candidates.push(join(resourcesPath, 'win-spawn-helper', binary));
41
- }
42
- // Packaged extraResources live under resourcesPath. cwd is the user's
43
- // launch directory and must not supply the confinement binary.
44
- // `electron .` sets defaultApp and does not copy extraResources there;
45
- // npm-published shell also cannot see the host checkout via __dirname.
46
- const packagedElectron = Boolean(resourcesPath) && electron.defaultApp !== true;
47
- if (!packagedElectron) {
48
- candidates.push(join(process.cwd(), 'resources', 'windows-spawn-helper', binary));
49
- candidates.push(join(__dirname, '..', '..', 'resources', 'windows-spawn-helper', binary));
50
- }
36
+ // Packaged desktop hosts pass the bundled helper through the override.
37
+ // npm-published shell cannot see the host checkout via __dirname.
38
+ const candidates = [
39
+ join(process.cwd(), 'resources', 'windows-spawn-helper', binary),
40
+ join(__dirname, '..', '..', 'resources', 'windows-spawn-helper', binary),
41
+ ];
51
42
  for (const candidate of candidates) {
52
43
  if (existsSync(candidate))
53
44
  return candidate;