mocode-ai 0.7.0 → 0.7.2
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/README.md +4 -3
- package/README.zh-CN.md +3 -3
- package/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +621 -260
- package/dist/agent/index.js +20 -0
- package/dist/agent/spawn.js +9 -1
- package/dist/config/index.js +12 -0
- package/dist/i18n/index.js +34 -0
- package/dist/llm/index.js +20 -2
- package/dist/mcp/index.js +15 -2
- package/dist/permissions/index.js +149 -93
- package/dist/repl/index.js +66 -10
- package/dist/rollback/index.js +36 -0
- package/dist/session/index.js +3 -0
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +54 -0
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +42 -4
- package/dist/tools/builtins/run-command.js +115 -66
- package/dist/tools/builtins/task.js +5 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/constants.js +5 -1
- package/dist/tools/registry.js +141 -39
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/affected.js +149 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +48 -0
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +333 -0
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/profile.js +237 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/dist/verification/types.js +1 -0
- package/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
|
-
import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
|
|
3
|
+
import { getSandboxRoot, filterEnv, isCommandDenied, jailResolve } from '../../sandbox/index.js';
|
|
4
4
|
import { t } from '../../i18n/index.js';
|
|
5
5
|
const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
|
|
6
6
|
const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
|
|
@@ -25,6 +25,119 @@ class BoundedCommandOutput {
|
|
|
25
25
|
return `${this.head}\n${t('command.outputTruncated', { count: removed })}\n${this.tail}`;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
|
|
29
|
+
export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const deny = isCommandDenied(command);
|
|
32
|
+
if (deny) {
|
|
33
|
+
return { status: 'denied', exitCode: null, output: `错误:${deny}`, durationMs: 0 };
|
|
34
|
+
}
|
|
35
|
+
let executionCwd = getSandboxRoot() ?? process.cwd();
|
|
36
|
+
if (cwd) {
|
|
37
|
+
try {
|
|
38
|
+
executionCwd = jailResolve(cwd);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return { status: 'denied', exitCode: null, output: `错误:${message}`, durationMs: 0 };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new Promise((done) => {
|
|
46
|
+
const isWin = process.platform === 'win32';
|
|
47
|
+
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/d', '/s', '/c', command] : ['-c', command], {
|
|
48
|
+
cwd: executionCwd,
|
|
49
|
+
env: filterEnv(process.env),
|
|
50
|
+
// Without this, Node re-quotes cmd.exe arguments and `node -e "..."` can become
|
|
51
|
+
// a string literal that exits 0, causing false-positive validation on Windows.
|
|
52
|
+
windowsVerbatimArguments: isWin,
|
|
53
|
+
});
|
|
54
|
+
const output = new BoundedCommandOutput();
|
|
55
|
+
let finished = false;
|
|
56
|
+
let timer;
|
|
57
|
+
const killTree = () => {
|
|
58
|
+
try {
|
|
59
|
+
if (isWin) {
|
|
60
|
+
if (child.pid != null) {
|
|
61
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
child.kill('SIGTERM');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Process already exited or best-effort termination failed.
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const finish = (result) => {
|
|
73
|
+
if (finished)
|
|
74
|
+
return;
|
|
75
|
+
finished = true;
|
|
76
|
+
if (timer)
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
signal?.removeEventListener('abort', onAbort);
|
|
79
|
+
done({ ...result, durationMs: Date.now() - startedAt });
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
killTree();
|
|
83
|
+
finish({ status: 'aborted', exitCode: null, output: output.render().trim() });
|
|
84
|
+
};
|
|
85
|
+
const onChunk = (chunk) => output.append(chunk.toString('utf8'));
|
|
86
|
+
child.stdout.on('data', onChunk);
|
|
87
|
+
child.stderr.on('data', onChunk);
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
finish({ status: 'spawn_error', exitCode: null, output: error.message });
|
|
90
|
+
});
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
finish({
|
|
93
|
+
status: code === 0 ? 'passed' : 'failed',
|
|
94
|
+
exitCode: code,
|
|
95
|
+
output: output.render().trim(),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
timer = setTimeout(() => {
|
|
99
|
+
killTree();
|
|
100
|
+
finish({ status: 'timed_out', exitCode: null, output: output.render().trim() });
|
|
101
|
+
}, timeout);
|
|
102
|
+
if (signal) {
|
|
103
|
+
if (signal.aborted)
|
|
104
|
+
onAbort();
|
|
105
|
+
else
|
|
106
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Preserve the public run_command text protocol while exposing structured status internally. */
|
|
111
|
+
export function formatCommandResult(result) {
|
|
112
|
+
const output = result.output.trim();
|
|
113
|
+
if (result.status === 'denied')
|
|
114
|
+
return result.output;
|
|
115
|
+
if (result.status === 'aborted')
|
|
116
|
+
return `${t('command.interrupted')}\n${output}`;
|
|
117
|
+
if (result.status === 'timed_out')
|
|
118
|
+
return `${t('command.timedOut')}\n${output}`;
|
|
119
|
+
if (result.status === 'spawn_error')
|
|
120
|
+
return t('command.executionFailed', { message: result.output });
|
|
121
|
+
return `${t('command.exitCode', { code: result.exitCode ?? 'null' })}\n${output || t('toolSummary.noOutput')}`;
|
|
122
|
+
}
|
|
123
|
+
/** Convert the raw process status into the common structured tool contract. */
|
|
124
|
+
function commandOutcome(result) {
|
|
125
|
+
const output = formatCommandResult(result);
|
|
126
|
+
switch (result.status) {
|
|
127
|
+
case 'passed':
|
|
128
|
+
return { status: 'success', code: 'OK', retryable: false, output, durationMs: result.durationMs };
|
|
129
|
+
case 'aborted':
|
|
130
|
+
return { status: 'aborted', code: 'ABORTED', retryable: false, output, durationMs: result.durationMs };
|
|
131
|
+
case 'denied':
|
|
132
|
+
return { status: 'denied', code: 'SANDBOX_DENIED', retryable: false, output, durationMs: result.durationMs };
|
|
133
|
+
case 'timed_out':
|
|
134
|
+
return { status: 'error', code: 'TIMEOUT', retryable: false, output, durationMs: result.durationMs };
|
|
135
|
+
case 'failed':
|
|
136
|
+
return { status: 'error', code: 'PROCESS_FAILED', retryable: false, output, durationMs: result.durationMs };
|
|
137
|
+
case 'spawn_error':
|
|
138
|
+
return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output, durationMs: result.durationMs };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
28
141
|
// ---------- run_command ----------
|
|
29
142
|
export const runCommandTool = {
|
|
30
143
|
name: 'run_command',
|
|
@@ -41,70 +154,6 @@ export const runCommandTool = {
|
|
|
41
154
|
async execute(args, ctx) {
|
|
42
155
|
const command = String(args.command);
|
|
43
156
|
const timeout = Number(args.timeout ?? 120000);
|
|
44
|
-
|
|
45
|
-
const deny = isCommandDenied(command);
|
|
46
|
-
if (deny)
|
|
47
|
-
return `错误:${deny}`;
|
|
48
|
-
return new Promise((done) => {
|
|
49
|
-
const isWin = process.platform === 'win32';
|
|
50
|
-
// 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
|
|
51
|
-
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
|
|
52
|
-
const output = new BoundedCommandOutput();
|
|
53
|
-
let finished = false;
|
|
54
|
-
let timer;
|
|
55
|
-
// 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
|
|
56
|
-
// 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
|
|
57
|
-
const killTree = () => {
|
|
58
|
-
try {
|
|
59
|
-
if (isWin) {
|
|
60
|
-
if (child.pid != null) {
|
|
61
|
-
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
62
|
-
stdio: 'ignore',
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
child.kill('SIGTERM');
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
// 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
// abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
|
|
75
|
-
const onAbort = () => {
|
|
76
|
-
killTree();
|
|
77
|
-
finish(`${t('command.interrupted')}\n${output.render().trim()}`);
|
|
78
|
-
};
|
|
79
|
-
const finish = (s) => {
|
|
80
|
-
if (finished)
|
|
81
|
-
return;
|
|
82
|
-
finished = true;
|
|
83
|
-
clearTimeout(timer);
|
|
84
|
-
ctx?.signal?.removeEventListener('abort', onAbort);
|
|
85
|
-
done(s);
|
|
86
|
-
};
|
|
87
|
-
const onChunk = (chunk) => {
|
|
88
|
-
output.append(chunk.toString('utf8'));
|
|
89
|
-
};
|
|
90
|
-
child.stdout.on('data', onChunk);
|
|
91
|
-
child.stderr.on('data', onChunk);
|
|
92
|
-
child.on('error', (e) => finish(t('command.executionFailed', { message: e.message })));
|
|
93
|
-
child.on('close', (code) => {
|
|
94
|
-
const result = output.render().trim();
|
|
95
|
-
finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
|
|
96
|
-
});
|
|
97
|
-
timer = setTimeout(() => {
|
|
98
|
-
killTree();
|
|
99
|
-
finish(`${t('command.timedOut')}\n${output.render().trim()}`);
|
|
100
|
-
}, timeout);
|
|
101
|
-
// 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
|
|
102
|
-
if (ctx?.signal) {
|
|
103
|
-
if (ctx.signal.aborted)
|
|
104
|
-
onAbort();
|
|
105
|
-
else
|
|
106
|
-
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
107
|
-
}
|
|
108
|
-
});
|
|
157
|
+
return commandOutcome(await runCommandRaw(command, timeout, ctx?.signal));
|
|
109
158
|
},
|
|
110
159
|
};
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawnAgent } from '../../agent/spawn.js';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
3
|
import { t } from '../../i18n/index.js';
|
|
4
|
+
import { isSubAgentEnabled } from '../../config/index.js';
|
|
4
5
|
// ---------- task ----------
|
|
5
6
|
// 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
|
|
6
7
|
// 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// 适用:分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
|
|
10
|
+
// 多个 task 在共享工作区期间由 capability scheduler 串行执行;隔离 workspace 落地后再开放并行写。
|
|
10
11
|
export const taskTool = {
|
|
11
12
|
name: 'task',
|
|
12
13
|
risk: 'dangerous',
|
|
@@ -35,6 +36,8 @@ export const taskTool = {
|
|
|
35
36
|
required: ['prompt'],
|
|
36
37
|
},
|
|
37
38
|
async execute(args, ctx) {
|
|
39
|
+
if (!isSubAgentEnabled())
|
|
40
|
+
return t('task.disabled');
|
|
38
41
|
const prompt = String(args.prompt ?? '');
|
|
39
42
|
if (!prompt)
|
|
40
43
|
return t('task.missingPrompt');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { verifyWrittenFile } from '../../verification/postconditions.js';
|
|
3
4
|
// ---------- write_file ----------
|
|
4
5
|
export const writeFileTool = {
|
|
5
6
|
name: 'write_file',
|
|
@@ -19,6 +20,17 @@ export const writeFileTool = {
|
|
|
19
20
|
const full = resolve(path);
|
|
20
21
|
await mkdir(dirname(full), { recursive: true });
|
|
21
22
|
await writeFile(full, content, 'utf8');
|
|
22
|
-
|
|
23
|
+
const postcondition = await verifyWrittenFile(full, content);
|
|
24
|
+
if (postcondition.status === 'failed') {
|
|
25
|
+
return {
|
|
26
|
+
status: 'error',
|
|
27
|
+
code: 'POSTCONDITION_FAILED',
|
|
28
|
+
retryable: true,
|
|
29
|
+
output: postcondition.diagnostics
|
|
30
|
+
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
|
|
31
|
+
.join('\n'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
|
|
23
35
|
},
|
|
24
36
|
};
|
package/dist/tools/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** 工具共享的截断 / 上限 / 忽略规则。 */
|
|
2
|
-
import { isMemoryEnabled } from '../config/index.js';
|
|
2
|
+
import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
|
|
3
3
|
export const MAX_FILE_LINES = 2000;
|
|
4
4
|
export const MAX_OUTPUT = 20000;
|
|
5
5
|
export const MAX_RESULTS = 100;
|
|
@@ -54,3 +54,7 @@ export function getPlanDisabledTools() {
|
|
|
54
54
|
next.delete('memory_forget');
|
|
55
55
|
return next;
|
|
56
56
|
}
|
|
57
|
+
/** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
|
|
58
|
+
export function getRuntimeDisabledTools() {
|
|
59
|
+
return isSubAgentEnabled() ? new Set() : new Set(['task']);
|
|
60
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { builtinTools } from './builtins/index.js';
|
|
2
|
-
import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, } from '../rollback/index.js';
|
|
2
|
+
import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
|
|
3
3
|
import { enforceSandbox } from '../sandbox/index.js';
|
|
4
|
+
import { resolveResourceLockRequests, toolResourceLockManager } from './resource-lock.js';
|
|
4
5
|
import { t } from '../i18n/index.js';
|
|
6
|
+
import { isToolErrorOutput } from './result.js';
|
|
5
7
|
/**
|
|
6
8
|
* 可扩展工具注册表。数组实例始终稳定,使已经持有 tools 引用的 agent/LLM 能看到运行时新增工具。
|
|
7
9
|
* 扩展按 source 替换,MCP 重连或配置刷新不会累积旧工具。
|
|
@@ -33,55 +35,155 @@ function rebuildTools() {
|
|
|
33
35
|
}
|
|
34
36
|
tools.splice(0, tools.length, ...next);
|
|
35
37
|
}
|
|
38
|
+
const DEFAULT_CAPABILITIES = Object.freeze({
|
|
39
|
+
effect: 'unknown',
|
|
40
|
+
concurrency: 'serial',
|
|
41
|
+
retry: 'never',
|
|
42
|
+
});
|
|
43
|
+
export function findTool(name) {
|
|
44
|
+
return tools.find((tool) => tool.name === name);
|
|
45
|
+
}
|
|
46
|
+
/** 缺少声明或找不到工具时返回保守能力,绝不把未知扩展并发执行。 */
|
|
47
|
+
export function getToolCapabilities(toolOrName) {
|
|
48
|
+
const tool = typeof toolOrName === 'string' ? findTool(toolOrName) : toolOrName;
|
|
49
|
+
return tool?.capabilities ?? DEFAULT_CAPABILITIES;
|
|
50
|
+
}
|
|
51
|
+
export function getToolResourceKeys(toolOrName, args) {
|
|
52
|
+
const capabilities = getToolCapabilities(toolOrName);
|
|
53
|
+
try {
|
|
54
|
+
return capabilities.resources?.(args) ?? [];
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** resource-locked write 是可生成文件 diff/按路径记 rollback 的文件 mutation。 */
|
|
61
|
+
export function isFileMutationTool(name) {
|
|
62
|
+
const capabilities = getToolCapabilities(name);
|
|
63
|
+
return capabilities.effect === 'write' && capabilities.concurrency === 'resource-locked';
|
|
64
|
+
}
|
|
65
|
+
function isStructuredOutcome(value) {
|
|
66
|
+
return typeof value === 'object' && value !== null &&
|
|
67
|
+
typeof value.status === 'string' && typeof value.code === 'string' &&
|
|
68
|
+
typeof value.retryable === 'boolean' && typeof value.output === 'string';
|
|
69
|
+
}
|
|
70
|
+
function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
|
|
71
|
+
if (isStructuredOutcome(value)) {
|
|
72
|
+
return {
|
|
73
|
+
...value,
|
|
74
|
+
durationMs: value.durationMs ?? durationMs,
|
|
75
|
+
changedFiles: value.changedFiles ?? changedFiles,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const failed = isToolErrorOutput(value);
|
|
79
|
+
return {
|
|
80
|
+
status: failed ? 'error' : 'success',
|
|
81
|
+
code: failed ? 'EXECUTION_ERROR' : 'OK',
|
|
82
|
+
retryable: failed && capabilities.retry !== 'never',
|
|
83
|
+
output: value,
|
|
84
|
+
changedFiles,
|
|
85
|
+
durationMs,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
|
|
89
|
+
return {
|
|
90
|
+
status,
|
|
91
|
+
code,
|
|
92
|
+
retryable: false,
|
|
93
|
+
output,
|
|
94
|
+
changedFiles,
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
36
98
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
|
|
40
|
-
* opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
|
|
99
|
+
* 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
|
|
100
|
+
* 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
|
|
41
101
|
*/
|
|
42
|
-
export async function
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return t('
|
|
102
|
+
export async function executeToolOutcome(name, argsRaw, signal, opts) {
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
106
|
+
}
|
|
107
|
+
const tool = findTool(name);
|
|
108
|
+
if (!tool) {
|
|
109
|
+
return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
|
|
110
|
+
}
|
|
46
111
|
let args;
|
|
47
112
|
try {
|
|
48
113
|
args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
49
114
|
}
|
|
50
115
|
catch {
|
|
51
|
-
return t('toolError.invalidJson', { name, arguments: argsRaw });
|
|
116
|
+
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
52
117
|
}
|
|
118
|
+
const capabilities = getToolCapabilities(tool);
|
|
119
|
+
let mutationVersionBefore;
|
|
120
|
+
let capturedPath;
|
|
53
121
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return sbErr;
|
|
58
|
-
const pathCapture = (name === 'write_file' || name === 'edit_file') &&
|
|
59
|
-
typeof args.path === 'string' &&
|
|
60
|
-
args.path
|
|
61
|
-
? beginPathMutation(args.path)
|
|
62
|
-
: null;
|
|
63
|
-
// shell 与 MCP 的副作用无法从参数可靠推断:以工作区前后状态识别实际改动。
|
|
64
|
-
// task 本身不扫描;其子 agent 共享当前轮,并在各自真实写工具处记账。
|
|
65
|
-
const workspaceCapture = name === 'run_command' || name.startsWith('mcp__')
|
|
66
|
-
? beginWorkspaceMutation()
|
|
67
|
-
: null;
|
|
68
|
-
try {
|
|
69
|
-
return await tool.execute(args, {
|
|
70
|
-
signal,
|
|
71
|
-
dropContext: opts?.dropContext,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
finally {
|
|
75
|
-
if (pathCapture)
|
|
76
|
-
endPathMutation(pathCapture, name);
|
|
77
|
-
if (workspaceCapture)
|
|
78
|
-
endWorkspaceMutation(workspaceCapture, name);
|
|
122
|
+
const sandboxError = enforceSandbox(name, args);
|
|
123
|
+
if (sandboxError) {
|
|
124
|
+
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
79
125
|
}
|
|
126
|
+
const requests = resolveResourceLockRequests(capabilities, args);
|
|
127
|
+
return await toolResourceLockManager.withLocks(requests, signal, async () => {
|
|
128
|
+
// Diff 等执行前观察必须发生在真正持锁之后;同路径排队调用才能看到前序写入结果。
|
|
129
|
+
opts?.onLockAcquired?.(args);
|
|
130
|
+
const mutationBefore = getCurrentTurnMutationState();
|
|
131
|
+
mutationVersionBefore = mutationBefore.version;
|
|
132
|
+
const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
|
|
133
|
+
? beginPathMutation(args.path)
|
|
134
|
+
: null;
|
|
135
|
+
capturedPath = pathCapture?.path;
|
|
136
|
+
// 进程和未知扩展可能间接改动任意文件;其 workspace lock 同时隔离全盘捕获。
|
|
137
|
+
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
138
|
+
? beginWorkspaceMutation()
|
|
139
|
+
: null;
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = await tool.execute(args, {
|
|
143
|
+
signal,
|
|
144
|
+
dropContext: opts?.dropContext,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (pathCapture)
|
|
149
|
+
endPathMutation(pathCapture, name);
|
|
150
|
+
if (workspaceCapture)
|
|
151
|
+
endWorkspaceMutation(workspaceCapture, name);
|
|
152
|
+
}
|
|
153
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
154
|
+
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
155
|
+
? pathCapture
|
|
156
|
+
? mutationAfter.changedFiles
|
|
157
|
+
.filter((item) => item.path === pathCapture.path)
|
|
158
|
+
.map((item) => item.path)
|
|
159
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
160
|
+
: [];
|
|
161
|
+
if (signal?.aborted) {
|
|
162
|
+
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
163
|
+
}
|
|
164
|
+
return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
|
|
165
|
+
});
|
|
80
166
|
}
|
|
81
|
-
catch (
|
|
82
|
-
|
|
167
|
+
catch (error) {
|
|
168
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
169
|
+
const changedFiles = mutationVersionBefore !== undefined &&
|
|
170
|
+
mutationAfter.version !== mutationVersionBefore
|
|
171
|
+
? capturedPath
|
|
172
|
+
? mutationAfter.changedFiles
|
|
173
|
+
.filter((item) => item.path === capturedPath)
|
|
174
|
+
.map((item) => item.path)
|
|
175
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
176
|
+
: [];
|
|
177
|
+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
178
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
|
|
179
|
+
}
|
|
180
|
+
return terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
|
|
83
181
|
name,
|
|
84
|
-
message:
|
|
85
|
-
});
|
|
182
|
+
message: error instanceof Error ? error.message : String(error),
|
|
183
|
+
}), startedAt, changedFiles);
|
|
86
184
|
}
|
|
87
185
|
}
|
|
186
|
+
/** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
|
|
187
|
+
export async function executeTool(name, argsRaw, signal, opts) {
|
|
188
|
+
return (await executeToolOutcome(name, argsRaw, signal, opts)).output;
|
|
189
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { normalize } from 'node:path';
|
|
2
|
+
import { jailResolve } from '../sandbox/index.js';
|
|
3
|
+
function abortError() {
|
|
4
|
+
const error = new Error('Resource lock acquisition aborted');
|
|
5
|
+
error.name = 'AbortError';
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
function requestConflicts(a, b) {
|
|
9
|
+
if (a.scope === 'workspace' || b.scope === 'workspace') {
|
|
10
|
+
if (a.mode === 'write' || b.mode === 'write')
|
|
11
|
+
return true;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return a.key === b.key && (a.mode === 'write' || b.mode === 'write');
|
|
15
|
+
}
|
|
16
|
+
function claimsConflict(a, b) {
|
|
17
|
+
return a.requests.some((left) => b.requests.some((right) => requestConflicts(left, right)));
|
|
18
|
+
}
|
|
19
|
+
/** Fair, abort-aware multi-resource read/write lock shared by all agent loops. */
|
|
20
|
+
export class ResourceLockManager {
|
|
21
|
+
active = new Set();
|
|
22
|
+
waiting = [];
|
|
23
|
+
acquire(requests, signal) {
|
|
24
|
+
if (signal?.aborted)
|
|
25
|
+
return Promise.reject(abortError());
|
|
26
|
+
const normalized = dedupeRequests(requests);
|
|
27
|
+
if (normalized.length === 0)
|
|
28
|
+
return Promise.resolve(() => undefined);
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const waiter = { requests: normalized, resolve, reject, signal };
|
|
31
|
+
if (signal) {
|
|
32
|
+
waiter.onAbort = () => {
|
|
33
|
+
const index = this.waiting.indexOf(waiter);
|
|
34
|
+
if (index < 0)
|
|
35
|
+
return;
|
|
36
|
+
this.waiting.splice(index, 1);
|
|
37
|
+
signal.removeEventListener('abort', waiter.onAbort);
|
|
38
|
+
reject(abortError());
|
|
39
|
+
this.dispatch();
|
|
40
|
+
};
|
|
41
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true });
|
|
42
|
+
}
|
|
43
|
+
this.waiting.push(waiter);
|
|
44
|
+
this.dispatch();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async withLocks(requests, signal, action) {
|
|
48
|
+
const release = await this.acquire(requests, signal);
|
|
49
|
+
try {
|
|
50
|
+
return await action();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
dispatch() {
|
|
57
|
+
const blocked = [];
|
|
58
|
+
for (let index = 0; index < this.waiting.length;) {
|
|
59
|
+
const waiter = this.waiting[index];
|
|
60
|
+
const conflictsActive = [...this.active].some((claim) => claimsConflict(waiter, claim));
|
|
61
|
+
const conflictsEarlier = blocked.some((claim) => claimsConflict(waiter, claim));
|
|
62
|
+
if (conflictsActive || conflictsEarlier) {
|
|
63
|
+
blocked.push(waiter);
|
|
64
|
+
index++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
this.waiting.splice(index, 1);
|
|
68
|
+
if (waiter.onAbort)
|
|
69
|
+
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
|
70
|
+
const claim = { requests: waiter.requests };
|
|
71
|
+
this.active.add(claim);
|
|
72
|
+
let released = false;
|
|
73
|
+
waiter.resolve(() => {
|
|
74
|
+
if (released)
|
|
75
|
+
return;
|
|
76
|
+
released = true;
|
|
77
|
+
this.active.delete(claim);
|
|
78
|
+
this.dispatch();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function dedupeRequests(requests) {
|
|
84
|
+
const byKey = new Map();
|
|
85
|
+
for (const request of requests) {
|
|
86
|
+
const identity = `${request.scope}:${request.key}`;
|
|
87
|
+
const existing = byKey.get(identity);
|
|
88
|
+
if (!existing || request.mode === 'write')
|
|
89
|
+
byKey.set(identity, request);
|
|
90
|
+
}
|
|
91
|
+
return [...byKey.values()].sort((a, b) => `${a.scope}:${a.key}`.localeCompare(`${b.scope}:${b.key}`));
|
|
92
|
+
}
|
|
93
|
+
/** Stable lock identity: sandbox realpath plus Windows case/separator normalization. */
|
|
94
|
+
export function canonicalFileResourceKey(input) {
|
|
95
|
+
let canonical = normalize(jailResolve(input));
|
|
96
|
+
if (process.platform === 'win32')
|
|
97
|
+
canonical = canonical.toLowerCase();
|
|
98
|
+
return `file:${canonical}`;
|
|
99
|
+
}
|
|
100
|
+
function modeFor(effect) {
|
|
101
|
+
return effect === 'read' ? 'read' : 'write';
|
|
102
|
+
}
|
|
103
|
+
const workspaceWrite = () => [{
|
|
104
|
+
key: 'workspace',
|
|
105
|
+
scope: 'workspace',
|
|
106
|
+
mode: 'write',
|
|
107
|
+
}];
|
|
108
|
+
/** Resolve declared logical resources. Any ambiguity fails closed to a workspace write lock. */
|
|
109
|
+
export function resolveResourceLockRequests(capabilities, args) {
|
|
110
|
+
if (capabilities.delegatesResourceLocks)
|
|
111
|
+
return [];
|
|
112
|
+
if (capabilities.effect === 'process' || capabilities.effect === 'unknown') {
|
|
113
|
+
return workspaceWrite();
|
|
114
|
+
}
|
|
115
|
+
let keys;
|
|
116
|
+
try {
|
|
117
|
+
keys = capabilities.resources?.(args) ?? [];
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return workspaceWrite();
|
|
121
|
+
}
|
|
122
|
+
if (keys.length === 0) {
|
|
123
|
+
return capabilities.effect === 'network' ? [] : workspaceWrite();
|
|
124
|
+
}
|
|
125
|
+
const mode = modeFor(capabilities.effect);
|
|
126
|
+
const requests = [];
|
|
127
|
+
try {
|
|
128
|
+
for (const key of keys) {
|
|
129
|
+
if (typeof key !== 'string' || key.trim().length === 0)
|
|
130
|
+
return workspaceWrite();
|
|
131
|
+
if (key === 'workspace') {
|
|
132
|
+
requests.push({ key, scope: 'workspace', mode });
|
|
133
|
+
}
|
|
134
|
+
else if (key.startsWith('file:') && key.length > 5) {
|
|
135
|
+
requests.push({ key: canonicalFileResourceKey(key.slice(5)), scope: 'resource', mode });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Non-file logical resources are still lockable, but never treated as filesystem paths.
|
|
139
|
+
requests.push({ key, scope: 'resource', mode });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return workspaceWrite();
|
|
145
|
+
}
|
|
146
|
+
return dedupeRequests(requests);
|
|
147
|
+
}
|
|
148
|
+
export const toolResourceLockManager = new ResourceLockManager();
|