@yeaft/webchat-agent 0.1.1014 → 0.1.1015

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1014",
3
+ "version": "0.1.1015",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
21
21
  import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
+ import { getRuntimePlatformInfo } from './runtime-platform.js';
24
25
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
25
26
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
26
27
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
@@ -815,6 +816,7 @@ export class Engine {
815
816
  activeScope,
816
817
  sessionAnnouncement,
817
818
  projectDoc,
819
+ runtimePlatform: getRuntimePlatformInfo(),
818
820
  taskCtx,
819
821
  // Worker-shape harness is descriptive metadata for human inspection;
820
822
  // production prompts skip it to save tokens. Re-enable via env when
@@ -903,6 +905,7 @@ export class Engine {
903
905
  return {
904
906
  signal,
905
907
  yeaftDir: this.#yeaftDir,
908
+ runtimePlatform: getRuntimePlatformInfo(),
906
909
  // Group-scoped working directory. Threaded from #runQuery({ workDir })
907
910
  // → set by web-bridge runVpTurn from sessionMeta.workDir. Tools read
908
911
  // `ctx.cwd` and resolve relative paths against it. Always absolute
package/yeaft/prompts.js CHANGED
@@ -25,6 +25,7 @@
25
25
  import { readFileSync, existsSync } from 'fs';
26
26
  import { join, dirname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
+ import { getRuntimePlatformInfo, renderRuntimePlatformPrompt } from './runtime-platform.js';
28
29
  import { DEFAULT_VPS } from './vp/seed-defaults.js';
29
30
 
30
31
  // ─── Template Loading (one-time at startup) ──────────────────────
@@ -307,6 +308,7 @@ export function buildSystemPrompt({
307
308
  vpPersona,
308
309
  sessionAnnouncement = '',
309
310
  projectDoc = '',
311
+ runtimePlatform,
310
312
  } = {}) {
311
313
  // Normalize app locales like `zh-CN` to prompt dictionary/template keys.
312
314
  const effectiveLang = normalizePromptLanguage(language);
@@ -370,7 +372,12 @@ export function buildSystemPrompt({
370
372
  parts.push(dreamTemplate || lang.dream);
371
373
  }
372
374
 
373
- // ─── 4. Tools + Tool Guidance ──────────────────────────
375
+ // ─── 4. Runtime Platform + Tools + Tool Guidance ──────
376
+ const runtimePlatformBlock = renderRuntimePlatformPrompt(runtimePlatform || getRuntimePlatformInfo(), effectiveLang);
377
+ if (runtimePlatformBlock) {
378
+ parts.push(runtimePlatformBlock);
379
+ }
380
+
374
381
  if (toolNames.length > 0) {
375
382
  parts.push(lang.tools(toolNames.join(', ')));
376
383
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * runtime-platform.js — Runtime OS/platform facts for prompts and tools.
3
+ *
4
+ * Keep OS detection in one place. Tools should read `ctx.runtimePlatform`
5
+ * instead of guessing from scattered `process.platform` checks.
6
+ */
7
+
8
+ const WINDOWS_PLATFORMS = new Set(['win32']);
9
+ const MAC_PLATFORMS = new Set(['darwin']);
10
+ const LINUX_PLATFORMS = new Set(['linux']);
11
+
12
+ /**
13
+ * @param {string | undefined | null} platform
14
+ * @returns {NodeJS.Platform | string}
15
+ */
16
+ export function normalizePlatform(platform) {
17
+ const raw = typeof platform === 'string' && platform.trim()
18
+ ? platform.trim().toLowerCase()
19
+ : process.platform;
20
+ if (raw === 'windows') return 'win32';
21
+ if (raw === 'mac' || raw === 'macos' || raw === 'osx') return 'darwin';
22
+ return raw;
23
+ }
24
+
25
+ /**
26
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
27
+ * @returns {{ command: string, argsPrefix: string[], family: 'powershell' | 'cmd' | 'posix' }}
28
+ */
29
+ export function resolveDefaultShell(opts = {}) {
30
+ const platform = normalizePlatform(opts.platform);
31
+ const env = opts.env || process.env;
32
+
33
+ if (WINDOWS_PLATFORMS.has(platform)) {
34
+ const configured = env.YEAFT_WINDOWS_SHELL || env.PWSH || env.POWERSHELL;
35
+ const shell = configured || 'powershell.exe';
36
+ const lower = shell.toLowerCase();
37
+ if (lower.includes('cmd.exe') || lower.endsWith('cmd')) {
38
+ return { command: shell, argsPrefix: ['/d', '/s', '/c'], family: 'cmd' };
39
+ }
40
+ return {
41
+ command: shell,
42
+ argsPrefix: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'],
43
+ family: 'powershell',
44
+ };
45
+ }
46
+
47
+ return {
48
+ command: env.SHELL || '/bin/bash',
49
+ argsPrefix: ['-c'],
50
+ family: 'posix',
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
56
+ */
57
+ export function getRuntimePlatformInfo(opts = {}) {
58
+ const platform = normalizePlatform(opts.platform);
59
+ const shell = resolveDefaultShell({ platform, env: opts.env });
60
+ const isWindows = WINDOWS_PLATFORMS.has(platform);
61
+ const isMacOS = MAC_PLATFORMS.has(platform);
62
+ const isLinux = LINUX_PLATFORMS.has(platform);
63
+
64
+ return Object.freeze({
65
+ platform,
66
+ os: isWindows ? 'Windows' : (isMacOS ? 'macOS' : (isLinux ? 'Linux' : platform)),
67
+ isWindows,
68
+ isMacOS,
69
+ isLinux,
70
+ pathSeparator: isWindows ? '\\' : '/',
71
+ defaultShell: shell.command,
72
+ shellFamily: shell.family,
73
+ shellArgsPrefix: shell.argsPrefix,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * @param {ReturnType<typeof getRuntimePlatformInfo>} info
79
+ * @param {string} [language]
80
+ */
81
+ export function renderRuntimePlatformPrompt(info = getRuntimePlatformInfo(), language = 'en') {
82
+ const shell = info.shellFamily === 'powershell'
83
+ ? `${info.defaultShell} (PowerShell syntax)`
84
+ : (info.shellFamily === 'cmd' ? `${info.defaultShell} (cmd.exe syntax)` : `${info.defaultShell} (POSIX shell syntax)`);
85
+
86
+ if ((language || '').toLowerCase().startsWith('zh')) {
87
+ return [
88
+ '## runtime_platform',
89
+ `当前 Agent 运行系统:${info.os} (${info.platform})`,
90
+ `默认命令 shell:${shell}`,
91
+ `路径分隔符:${info.pathSeparator}`,
92
+ '生成 Bash 工具命令时必须匹配当前系统;Windows 上优先使用 PowerShell/cmd 语法,不要默认输出 Linux-only 命令。',
93
+ ].join('\n');
94
+ }
95
+
96
+ return [
97
+ '## runtime_platform',
98
+ `Agent OS: ${info.os} (${info.platform})`,
99
+ `Default command shell: ${shell}`,
100
+ `Path separator: ${info.pathSeparator}`,
101
+ 'When generating Bash tool commands, match this OS. On Windows, prefer PowerShell/cmd syntax instead of Linux-only commands.',
102
+ ].join('\n');
103
+ }
@@ -76,16 +76,10 @@ You are participating in the current session. Keep the user's context, answer fr
76
76
 
77
77
  - 使用紧凑的 GitHub 风格 Markdown。
78
78
  - 先给结论;不要一句话一段。
79
- <<<<<<< HEAD
80
79
  - 列表用于并列信息,不要把每句话都拆成列表项。
81
- - 围栏代码块 只用于代码、命令、配置、diff 或日志,并写语言标识。
82
- - 文件路径用 行内代码,例如 `agent/yeaft/prompts.js`。
83
- =======
84
- - 列表用于并列信息,不要把每句话都拆成 bullet。
85
- - fenced code block 只用于代码、命令、配置、diff 或日志,并写语言标识。
80
+ - 围栏代码块只用于代码、命令、配置、diff 或日志,并写语言标识。
86
81
  - 不要把普通说明、摘要、标签、标题、列表或单个词包进 fenced code block。
87
82
  - 文件路径、命令、标识符、状态值或短文本用 inline code,不要用 fenced code block。
88
83
  - 文件路径用 inline code,例如 `agent/yeaft/prompts.js`。
89
- >>>>>>> origin/main
90
84
  - 开发总结用 `改动 / 验证 / 风险` 或等价的简洁结构。
91
85
  - 评审用 `结论 / Findings / 验证`。
@@ -89,15 +89,10 @@
89
89
  - 使用 GitHub 风格 Markdown。
90
90
  - 普通说明写成紧凑自然段,不要一句话一段。
91
91
  - 并列信息用扁平列表,避免深层嵌套。
92
- <<<<<<< HEAD
93
92
  - 围栏代码块只用于真正的代码、命令、配置、diff、日志或用户需要精确复制的文本,并始终带语言标识。
94
- - 文件路径用行内代码,例如 `agent/yeaft/prompts.js`。
95
- =======
96
- - fenced code block 只用于真正的代码、命令、配置、diff、日志或用户需要精确复制的文本,并始终带语言标识。
97
93
  - 不要把普通说明、摘要、标签、标题、列表或单个词包进 fenced code block。
98
94
  - 文件路径、命令、标识符、状态值或短文本用 inline code,不要用 fenced code block。
99
95
  - 文件路径用 inline code,例如 `agent/yeaft/prompts.js`。
100
- >>>>>>> origin/main
101
96
  - 开发完成汇报使用:`改动`、`验证`、`风险 / 下一步`。
102
97
  - 评审使用:`结论`、`发现项`、`验证`。
103
98
  - 排障在需要时使用:`现象`、`证据`、`修复`、`验证`;简单问题保持更短。
@@ -4,13 +4,15 @@
4
4
  * Spawns a child process to run shell commands with timeout, output
5
5
  * truncation, working directory support, and cancellation via AbortSignal.
6
6
  *
7
- * Modeled after Claude Code's Bash tool implementation.
7
+ * The tool name remains Bash for wire compatibility. Internally it uses the
8
+ * platform default shell: POSIX shell on Linux/macOS, PowerShell/cmd on Windows.
8
9
  */
9
10
 
10
11
  import { defineTool } from './types.js';
11
12
  import { spawn } from 'child_process';
12
13
  import { existsSync } from 'fs';
13
14
  import { resolve } from 'path';
15
+ import { getRuntimePlatformInfo, resolveDefaultShell } from '../runtime-platform.js';
14
16
 
15
17
  /** Max output size in bytes before truncation (256 KB). */
16
18
  const MAX_OUTPUT = 256 * 1024;
@@ -21,18 +23,44 @@ const DEFAULT_TIMEOUT_MS = 120_000;
21
23
  /** Max timeout in ms (10 minutes). */
22
24
  const MAX_TIMEOUT_MS = 600_000;
23
25
 
26
+ /**
27
+ * @param {string} command
28
+ * @param {{ runtimePlatform?: object }} opts
29
+ */
30
+ export function buildShellInvocation(command, opts = {}) {
31
+ const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
32
+ const shell = runtimePlatform.defaultShell
33
+ ? {
34
+ command: runtimePlatform.defaultShell,
35
+ argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
36
+ family: runtimePlatform.shellFamily,
37
+ }
38
+ : resolveDefaultShell({ platform: runtimePlatform.platform });
39
+
40
+ const argsPrefix = Array.isArray(shell.argsPrefix)
41
+ ? shell.argsPrefix
42
+ : resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
43
+
44
+ return {
45
+ command: shell.command,
46
+ args: [...argsPrefix, command],
47
+ family: shell.family || runtimePlatform.shellFamily || 'posix',
48
+ };
49
+ }
50
+
24
51
  /**
25
52
  * Run a command in a child process.
26
53
  * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
27
54
  */
28
- function runCommand(command, { cwd, timeout, signal }) {
29
- return new Promise((resolve, reject) => {
30
- const shell = process.env.SHELL || '/bin/bash';
31
- const proc = spawn(shell, ['-c', command], {
55
+ function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
56
+ return new Promise((resolve) => {
57
+ const platform = runtimePlatform || getRuntimePlatformInfo();
58
+ const invocation = buildShellInvocation(command, { runtimePlatform: platform });
59
+ const proc = spawn(invocation.command, invocation.args, {
32
60
  cwd,
33
61
  env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
34
62
  stdio: ['ignore', 'pipe', 'pipe'],
35
- timeout,
63
+ detached: !platform.isWindows,
36
64
  });
37
65
 
38
66
  let stdout = '';
@@ -40,6 +68,31 @@ function runCommand(command, { cwd, timeout, signal }) {
40
68
  let stdoutTruncated = false;
41
69
  let stderrTruncated = false;
42
70
  let timedOut = false;
71
+ let settled = false;
72
+ let timeoutId = null;
73
+
74
+ const finish = (result) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ if (timeoutId) clearTimeout(timeoutId);
78
+ resolve(result);
79
+ };
80
+
81
+ const killProcess = () => {
82
+ try {
83
+ if (!platform.isWindows && proc.pid) {
84
+ process.kill(-proc.pid, 'SIGTERM');
85
+ return;
86
+ }
87
+ } catch {
88
+ // Fall back to killing the shell process below.
89
+ }
90
+ try {
91
+ proc.kill('SIGTERM');
92
+ } catch {
93
+ // ignore
94
+ }
95
+ };
43
96
 
44
97
  proc.stdout.on('data', (chunk) => {
45
98
  if (stdout.length < MAX_OUTPUT) {
@@ -61,46 +114,41 @@ function runCommand(command, { cwd, timeout, signal }) {
61
114
  }
62
115
  });
63
116
 
64
- // Handle abort signal
65
- const onAbort = () => {
66
- try { proc.kill('SIGTERM'); } catch {}
67
- setTimeout(() => {
68
- try { proc.kill('SIGKILL'); } catch {}
69
- }, 2000);
70
- };
117
+ timeoutId = setTimeout(() => {
118
+ timedOut = true;
119
+ killProcess();
120
+ finish({
121
+ stdout,
122
+ stderr: stderr + `\nProcess timed out after ${timeout}ms`,
123
+ exitCode: 124,
124
+ timedOut: true,
125
+ });
126
+ }, timeout);
127
+
71
128
  if (signal) {
72
- if (signal.aborted) { onAbort(); return; }
73
- signal.addEventListener('abort', onAbort, { once: true });
129
+ signal.addEventListener('abort', () => {
130
+ killProcess();
131
+ }, { once: true });
74
132
  }
75
133
 
76
- proc.on('close', (code) => {
77
- if (signal) signal.removeEventListener('abort', onAbort);
78
- resolve({
79
- stdout: stdoutTruncated ? stdout + '\n... (output truncated)' : stdout,
80
- stderr: stderrTruncated ? stderr + '\n... (stderr truncated)' : stderr,
81
- exitCode: code ?? 1,
134
+ proc.on('close', (code, signalName) => {
135
+ if (stdoutTruncated) stdout += '\n[Output truncated]';
136
+ if (stderrTruncated) stderr += '\n[Output truncated]';
137
+ finish({
138
+ stdout,
139
+ stderr,
140
+ exitCode: timedOut ? 124 : (code ?? (signalName ? 128 : 1)),
82
141
  timedOut,
83
142
  });
84
143
  });
85
144
 
86
145
  proc.on('error', (err) => {
87
- if (signal) signal.removeEventListener('abort', onAbort);
88
- if (err.code === 'ETIMEDOUT' || err.killed) {
89
- timedOut = true;
90
- resolve({
91
- stdout,
92
- stderr: stderr + `\nProcess timed out after ${timeout}ms`,
93
- exitCode: 124,
94
- timedOut: true,
95
- });
96
- } else {
97
- resolve({
98
- stdout,
99
- stderr: `Error spawning process: ${err.message}`,
100
- exitCode: 1,
101
- timedOut: false,
102
- });
103
- }
146
+ finish({
147
+ stdout,
148
+ stderr: `Error spawning process: ${err.message}`,
149
+ exitCode: 1,
150
+ timedOut: false,
151
+ });
104
152
  });
105
153
  });
106
154
  }
@@ -109,10 +157,13 @@ export default defineTool({
109
157
  name: 'Bash',
110
158
  description: `Execute a shell command and return its output.
111
159
 
112
- Use this tool to run CLI commands, scripts, and system operations.
160
+ Use this tool to run CLI commands, scripts, and system operations. The tool name
161
+ is kept as Bash for compatibility; on Windows the command is executed through
162
+ the configured Windows shell (PowerShell by default, or cmd when configured).
113
163
 
114
164
  Guidelines:
115
165
  - Commands run in the working directory (cwd from context)
166
+ - Match command syntax to the Agent OS shown in the runtime_platform prompt
116
167
  - Timeout defaults to 2 minutes (max 10 minutes)
117
168
  - Large outputs are truncated at 256KB
118
169
  - Use absolute paths when possible
@@ -124,7 +175,7 @@ Guidelines:
124
175
  properties: {
125
176
  command: {
126
177
  type: 'string',
127
- description: 'The shell command to execute',
178
+ description: 'The shell command to execute using the Agent OS default shell',
128
179
  },
129
180
  cwd: {
130
181
  type: 'string',
@@ -143,8 +194,9 @@ Guidelines:
143
194
  if (!input?.command) return false;
144
195
  const cmd = input.command.toLowerCase();
145
196
  return cmd.includes('rm ') || cmd.includes('rmdir') ||
197
+ cmd.includes('remove-item') || cmd.startsWith('del ') || cmd.includes(' del ') ||
146
198
  cmd.includes('git reset --hard') || cmd.includes('git clean') ||
147
- cmd.includes('dd ') || cmd.includes('mkfs') ||
199
+ cmd.includes('dd ') || cmd.includes('mkfs') || cmd.includes('format ') ||
148
200
  cmd.includes('> /dev/') || cmd.includes('chmod 000');
149
201
  },
150
202
  async execute(input, ctx) {
@@ -162,12 +214,14 @@ Guidelines:
162
214
 
163
215
  // Clamp timeout
164
216
  const timeout = Math.min(Math.max(timeout_ms || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
217
+ const runtimePlatform = ctx?.runtimePlatform || getRuntimePlatformInfo();
165
218
 
166
219
  try {
167
220
  const result = await runCommand(command, {
168
221
  cwd,
169
222
  timeout,
170
223
  signal: ctx?.signal,
224
+ runtimePlatform,
171
225
  });
172
226
 
173
227
  // Format output similar to Claude Code
@@ -176,13 +230,13 @@ Guidelines:
176
230
  if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
177
231
  if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
178
232
 
179
- const output = parts.join('\n') || '(no output)';
180
-
181
- return result.exitCode === 0
182
- ? output
183
- : `Exit code: ${result.exitCode}\n${output}`;
233
+ const output = parts.join('\n');
234
+ if (result.exitCode !== 0) {
235
+ return `Exit code: ${result.exitCode}\n${output}`;
236
+ }
237
+ return output || '(no output)';
184
238
  } catch (err) {
185
- return JSON.stringify({ error: `Bash execution failed: ${err.message}` });
239
+ return JSON.stringify({ error: err.message });
186
240
  }
187
241
  },
188
242
  });
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { defineTool } from './types.js';
12
- import { execSync } from 'child_process';
12
+ import { execFileSync } from 'child_process';
13
13
  import { existsSync, mkdirSync } from 'fs';
14
14
  import { join, resolve } from 'path';
15
15
  import { randomUUID } from 'crypto';
@@ -45,7 +45,7 @@ Returns the worktree path and branch name.`,
45
45
 
46
46
  // Verify we're in a git repo
47
47
  try {
48
- execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
48
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
49
49
  } catch {
50
50
  return JSON.stringify({ error: 'Not in a git repository' });
51
51
  }
@@ -75,9 +75,9 @@ Returns the worktree path and branch name.`,
75
75
  }
76
76
 
77
77
  try {
78
- // Create worktree with new branch
79
- const cmd = `git worktree add -b "${branchName}" "${worktreeDir}" ${baseRef}`;
80
- execSync(cmd, { cwd, stdio: 'pipe' });
78
+ // Create worktree with new branch. Use execFileSync args instead of
79
+ // shell quoting so Windows drive letters and spaces in paths survive.
80
+ execFileSync('git', ['worktree', 'add', '-b', branchName, worktreeDir, baseRef], { cwd, stdio: 'pipe' });
81
81
 
82
82
  return JSON.stringify({
83
83
  success: true,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { defineTool } from './types.js';
11
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
  import { existsSync } from 'fs';
13
13
  import { resolve } from 'path';
14
14
 
@@ -66,7 +66,7 @@ unless discard_changes is set to true.`,
66
66
  // Check for uncommitted changes
67
67
  if (!input.discard_changes) {
68
68
  try {
69
- const status = execSync('git status --porcelain', {
69
+ const status = execFileSync('git', ['status', '--porcelain'], {
70
70
  cwd: worktreePath,
71
71
  encoding: 'utf8',
72
72
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -86,7 +86,7 @@ unless discard_changes is set to true.`,
86
86
  // Get branch name before removal
87
87
  let branchName = null;
88
88
  try {
89
- branchName = execSync('git rev-parse --abbrev-ref HEAD', {
89
+ branchName = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
90
90
  cwd: worktreePath,
91
91
  encoding: 'utf8',
92
92
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -95,9 +95,9 @@ unless discard_changes is set to true.`,
95
95
  // ignore
96
96
  }
97
97
 
98
- // Remove worktree
99
- const forceFlag = input.discard_changes ? ' --force' : '';
100
- execSync(`git worktree remove "${worktreePath}"${forceFlag}`, {
98
+ // Remove worktree. Use argv form so Windows paths with spaces or drive
99
+ // letters are passed to git unchanged.
100
+ execFileSync('git', ['worktree', 'remove', ...(input.discard_changes ? ['--force'] : []), worktreePath], {
101
101
  cwd: mainCwd,
102
102
  stdio: 'pipe',
103
103
  });
@@ -105,7 +105,7 @@ unless discard_changes is set to true.`,
105
105
  // Remove the branch if it was a yeaft worktree branch
106
106
  if (branchName && branchName.startsWith('yeaft-wt/')) {
107
107
  try {
108
- execSync(`git branch -D "${branchName}"`, {
108
+ execFileSync('git', ['branch', '-D', branchName], {
109
109
  cwd: mainCwd,
110
110
  stdio: 'pipe',
111
111
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * path-safety.js — Cross-platform path containment helpers for tools.
3
+ */
4
+
5
+ import path from 'path';
6
+
7
+ /**
8
+ * Return true when child is equal to or inside parent for the supplied path
9
+ * implementation (`path` on the host, or `path.win32`/`path.posix` in tests).
10
+ *
11
+ * @param {string} parent
12
+ * @param {string} child
13
+ * @param {{ relative: Function, isAbsolute: Function, resolve: Function }} [pathImpl]
14
+ */
15
+ export function isPathInsideOrEqual(parent, child, pathImpl = path) {
16
+ if (!parent || !child) return false;
17
+ const base = pathImpl.resolve(parent);
18
+ const target = pathImpl.resolve(child);
19
+ const rel = pathImpl.relative(base, target);
20
+ return rel === '' || (!!rel && !rel.startsWith('..') && !pathImpl.isAbsolute(rel));
21
+ }
22
+
23
+ /**
24
+ * @param {string} absPath
25
+ * @param {string} cwd
26
+ * @param {string[]} [allowlist]
27
+ * @param {{ relative: Function, isAbsolute: Function, resolve: Function }} [pathImpl]
28
+ */
29
+ export function checkPathAllowed(absPath, cwd, allowlist = [], pathImpl = path) {
30
+ if (isPathInsideOrEqual(cwd, absPath, pathImpl)) return null;
31
+
32
+ if (Array.isArray(allowlist)) {
33
+ for (const dir of allowlist) {
34
+ if (typeof dir !== 'string' || !pathImpl.isAbsolute(dir)) continue;
35
+ if (isPathInsideOrEqual(dir, absPath, pathImpl)) return null;
36
+ }
37
+ }
38
+
39
+ const inputWasAbs = pathImpl.isAbsolute(absPath);
40
+ return inputWasAbs
41
+ ? {
42
+ kind: 'absolute_outside_allowlist',
43
+ message: 'Absolute image paths must be inside the project directory or a ctx.imageAllowlist directory.',
44
+ }
45
+ : {
46
+ kind: 'relative_escape',
47
+ message: 'Relative image paths may not escape the working directory.',
48
+ };
49
+ }
@@ -14,6 +14,8 @@
14
14
  * @typedef {Object} ToolContext
15
15
  * @property {AbortSignal} [signal] — cancellation signal
16
16
  * @property {string} [yeaftDir] — Yeaft data directory
17
+ * @property {ReturnType<import('../runtime-platform.js').getRuntimePlatformInfo>} [runtimePlatform]
18
+ * — runtime OS/shell facts for platform-aware tools
17
19
  * @property {string} [cwd] — working directory
18
20
  * @property {import('../mcp.js').MCPManager} [mcpManager] — MCP manager
19
21
  * @property {object} [skillManager] — Skill manager
@@ -27,7 +27,8 @@
27
27
  import { defineTool } from './types.js';
28
28
  import { stat, readFile } from 'fs/promises';
29
29
  import { existsSync } from 'fs';
30
- import { resolve, extname, isAbsolute, relative } from 'path';
30
+ import { resolve, extname, isAbsolute } from 'path';
31
+ import { checkPathAllowed } from './path-safety.js';
31
32
 
32
33
  /** Default max image size in bytes (20 MiB). Override via ctx.maxImageBytes. */
33
34
  const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -109,36 +110,6 @@ function parseImageDimensions(buffer, ext) {
109
110
  return null;
110
111
  }
111
112
 
112
- /**
113
- * Check whether `absPath` is allowed given a project `cwd` and an optional
114
- * allowlist of absolute directories. Returns `null` on success, or an object
115
- * `{ kind, message }` describing the failure. The `kind` field lets callers
116
- * tailor the error text (see prev-3 P2: distinguish "absolute path outside
117
- * project" from "relative path containing ..").
118
- */
119
- function checkPathAllowed(absPath, cwd, allowlist) {
120
- // Reject if the resolved path lives inside the project (good).
121
- const relToCwd = relative(cwd, absPath);
122
- const insideCwd = relToCwd && !relToCwd.startsWith('..') && !isAbsolute(relToCwd);
123
- if (insideCwd) return null;
124
-
125
- // Otherwise must match an allowlist entry.
126
- if (Array.isArray(allowlist) && allowlist.length > 0) {
127
- for (const dir of allowlist) {
128
- if (typeof dir !== 'string' || !isAbsolute(dir)) continue;
129
- const rel = relative(dir, absPath);
130
- if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return null;
131
- }
132
- }
133
-
134
- return {
135
- kind: 'path_outside',
136
- message:
137
- 'Path is outside the project directory and not on the image allowlist. ' +
138
- 'Either move the file into the project, or ask the user to add its parent ' +
139
- 'directory to ctx.imageAllowlist (set via ~/.yeaft/config.json imageAllowlist[]).',
140
- };
141
- }
142
113
 
143
114
  function formatBytes(n) {
144
115
  if (n < 1024) return `${n}B`;