@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 +1 -1
- package/yeaft/engine.js +3 -0
- package/yeaft/prompts.js +8 -1
- package/yeaft/runtime-platform.js +103 -0
- package/yeaft/templates/base.md +1 -7
- package/yeaft/templates/common-rules.md +0 -5
- package/yeaft/tools/bash.js +101 -47
- package/yeaft/tools/enter-worktree.js +5 -5
- package/yeaft/tools/exit-worktree.js +7 -7
- package/yeaft/tools/path-safety.js +49 -0
- package/yeaft/tools/types.js +2 -0
- package/yeaft/tools/view-image.js +2 -31
package/package.json
CHANGED
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
|
+
}
|
package/yeaft/templates/base.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
- 排障在需要时使用:`现象`、`证据`、`修复`、`验证`;简单问题保持更短。
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
129
|
+
signal.addEventListener('abort', () => {
|
|
130
|
+
killProcess();
|
|
131
|
+
}, { once: true });
|
|
74
132
|
}
|
|
75
133
|
|
|
76
|
-
proc.on('close', (code) => {
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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')
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/yeaft/tools/types.js
CHANGED
|
@@ -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
|
|
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`;
|