autoclaw 1.3.3 → 1.3.5
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 +303 -289
- package/README.zh-CN.md +15 -1
- package/dist/agent.js +92 -7
- package/dist/batch.js +2 -0
- package/dist/index.js +5 -4
- package/dist/sandbox.js +94 -0
- package/dist/shell.js +55 -32
- package/dist/tools/background.js +165 -0
- package/dist/tools/core.js +44 -3
- package/dist/tools/index.js +5 -1
- package/package.json +1 -1
package/README.zh-CN.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/autoclaw)
|
|
5
5
|
[](https://github.com/tsingliuwin/autoclaw)
|
|
6
6
|
[](https://github.com/tsingliuwin/autoclaw/blob/main/LICENSE)
|
|
7
|
+
[](./SAFETY.md)
|
|
7
8
|
[](http://makeapullrequest.com)
|
|
8
9
|
|
|
9
10
|
**稳定、高工程化、易规模化:专为无界面系统设计的高效自动化 Agent 框架。**
|
|
@@ -34,7 +35,8 @@ AutoClaw 是一款针对 **“无界面系统” (Headless Systems)** 的高稳
|
|
|
34
35
|
- 📜 **无头执行 (Headless Execution)**: 无需图形界面,纯终端运行。核心流程仅依赖 Shell 与文件操作;可选的网页工具在无头 Chromium 中运行。
|
|
35
36
|
- 🤖 **非交互模式**: 支持自动化标志(`-y`, `--no-interactive`),完美适配零干预的自动化流程。
|
|
36
37
|
- 📂 **全方位控制 (Universal Control)**: 从基础的文件 I/O 到复杂的系统管理。
|
|
37
|
-
-
|
|
38
|
+
- 🖥️ **后台进程**: 后台启动长期命令(开发服务器、watcher)并轮询输出,不阻塞任务运行。
|
|
39
|
+
- 🛡️ **安全护栏**: 破坏性命令闸、凭据文件防线、步数上限、墙钟超时与 API 重试——为没有人看着的机器而设计。
|
|
38
40
|
- 🧠 **上下文感知 (Context Aware)**: 提供精确的操作系统与时间上下文,正确处理"今天"、"下周一"等相对时间。
|
|
39
41
|
- 🌐 **网页搜索**: 集成 Tavily,支持实时信息检索。
|
|
40
42
|
- 🌍 **网页阅读与截图**: 提取文章正文、截取页面图片(需先执行 `npx playwright install chromium`)。
|
|
@@ -170,6 +172,14 @@ autoclaw "将 src/index.ts 重构为使用 ES 模块" -y
|
|
|
170
172
|
|
|
171
173
|
### 诊断
|
|
172
174
|
`autoclaw doctor` 无头完成全面自检,逐项打印 ✓/✗:配置文件、解析出的 provider/baseUrl/model、API key、真实连接测试、解析出的 shell、已注册工具、playwright 浏览器状态。退出 `0` = 就绪,`1` = 有关键项失败(会打印是哪项)。适合 CI 或新机器。
|
|
175
|
+
|
|
176
|
+
### 沙箱
|
|
177
|
+
命令执行可通过 `config.sandbox` / `AUTOCLOW_SANDBOX` 加以约束(词汇借鉴自 DeepSeek Harness):
|
|
178
|
+
- `danger-full-access`(默认):命令不受约束地运行。
|
|
179
|
+
- `workspace-write`:命令只能在当前工作目录和 `/tmp` 内写入。
|
|
180
|
+
- `read-only`:命令无法在任何位置写入。
|
|
181
|
+
|
|
182
|
+
后端:Linux 使用 bubblewrap(`apt install bubblewrap`),macOS 使用 `sandbox-exec`。**Windows 暂无后端**——非默认模式会"失败关闭"(直接拒绝命令并给出明确错误),而不是假装约束;在 Windows 上请暂用 `danger-full-access`。此词汇不约束读取与网络。
|
|
173
183
|
- `--json`: 在 stdout 输出 NDJSON 事件流 (供编排器使用,配合 `-n`)。
|
|
174
184
|
|
|
175
185
|
### Provider 预设
|
|
@@ -196,6 +206,8 @@ AutoClaw 使用层级配置系统。
|
|
|
196
206
|
- `model`: 默认使用的模型。
|
|
197
207
|
- `maxSteps`: 单任务最大 LLM 轮数,超出后自动停止 (默认: `25`)。
|
|
198
208
|
- `shellTimeout`: Shell 命令超时时间(毫秒)(默认: `120000`)。
|
|
209
|
+
- `taskTimeoutMs`: 单任务整体墙钟超时(毫秒,默认关闭;会中断进行中的 API 调用并以 `timeout` 状态停止)。
|
|
210
|
+
- `sandbox`: 约束 shell 命令(`read-only`、`workspace-write`、`danger-full-access`;默认 `danger-full-access`)。
|
|
199
211
|
- `shell`: 强制 `execute_shell_command` 使用的 shell (`bash`、`powershell`、`cmd`、`sh`;默认自动检测——Windows 上优先 Git Bash > PowerShell > cmd)。
|
|
200
212
|
- `tavilyApiKey`: Tavily 网页搜索的 API 密钥。
|
|
201
213
|
- `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP 邮件设置。
|
|
@@ -216,6 +228,8 @@ AutoClaw 使用层级配置系统。
|
|
|
216
228
|
- `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: 主模型设置。
|
|
217
229
|
- `AUTOCLOW_PROVIDER`: 未传 `-P` 时使用的 provider 预设。
|
|
218
230
|
- `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: 稳定性限制(单任务最大轮数;Shell 超时毫秒数)。
|
|
231
|
+
- `AUTOCLOW_TASK_TIMEOUT_MS`: 单任务整体墙钟超时(毫秒)。
|
|
232
|
+
- `AUTOCLOW_SANDBOX`: 约束 shell 命令(`read-only`、`workspace-write`、`danger-full-access`)。
|
|
219
233
|
- `AUTOCLOW_SHELL`: 强制 shell 命令使用的 shell (`bash`、`powershell`、`cmd`、`sh`)。
|
|
220
234
|
- `AUTOCLOW_INCLUDE_USAGE`: 设为 `1`/`true` 时向 API 请求 token 用量(可选开启)。
|
|
221
235
|
- `TAVILY_API_KEY`, `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`, `FEISHU_WEBHOOK`/`FEISHU_KEYWORD`, `DINGTALK_WEBHOOK`/`DINGTALK_KEYWORD`, `WECOM_WEBHOOK`/`WECOM_KEYWORD`: 工具凭据,可作为 setup 的替代方式。
|
package/dist/agent.js
CHANGED
|
@@ -11,6 +11,21 @@ import { truncateOutput } from './truncate.js';
|
|
|
11
11
|
import { buildShellInfo, resolveShellType } from './shell.js';
|
|
12
12
|
const DEFAULT_MAX_STEPS = 25;
|
|
13
13
|
const TOOL_RESULT_TRIM_MARKER = 'older tool output trimmed';
|
|
14
|
+
// Canonical JSON: sorted object keys, so equivalent arguments from
|
|
15
|
+
// different model turns map to the same repeat signature.
|
|
16
|
+
function canonicalArgs(args) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.stringify(args, (_key, value) => {
|
|
19
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
20
|
+
return Object.keys(value).sort().reduce((acc, k) => { acc[k] = value[k]; return acc; }, {});
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return String(args);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
14
29
|
export class Agent {
|
|
15
30
|
client;
|
|
16
31
|
messages;
|
|
@@ -51,6 +66,7 @@ System Information:
|
|
|
51
66
|
has('web_search') ? '- Web: web_search — real-time information lookup' : null,
|
|
52
67
|
has('read_website') ? '- Web: read_website — extract article content from a URL' : null,
|
|
53
68
|
has('take_screenshot') ? '- Web: take_screenshot — capture page visuals' : null,
|
|
69
|
+
has('start_background_process') ? '- Processes: start_background_process / check_background_process / stop_background_process — run long-lived commands (servers, watchers) in the background and poll their output' : null,
|
|
54
70
|
has('send_email') ? '- Communication: send_email — SMTP email delivery' : null,
|
|
55
71
|
has('send_notification') ? '- Communication: send_notification — push to Feishu/DingTalk/WeCom' : null,
|
|
56
72
|
has('generate_image') ? '- Creation: generate_image — AI image generation (DALL-E compatible)' : null,
|
|
@@ -104,11 +120,20 @@ RULES OF ENGAGEMENT:
|
|
|
104
120
|
async chat(userInput) {
|
|
105
121
|
this.messages.push({ role: "user", content: userInput });
|
|
106
122
|
const maxSteps = Number(this.config?.maxSteps || process.env.AUTOCLOW_MAX_STEPS || DEFAULT_MAX_STEPS);
|
|
123
|
+
const taskTimeoutMs = Number(this.config?.taskTimeoutMs || process.env.AUTOCLOW_TASK_TIMEOUT_MS || 0);
|
|
124
|
+
const deadline = taskTimeoutMs > 0 ? Date.now() + taskTimeoutMs : Number.POSITIVE_INFINITY;
|
|
125
|
+
const abortController = new AbortController();
|
|
126
|
+
const abortTimer = taskTimeoutMs > 0
|
|
127
|
+
? setTimeout(() => abortController.abort(new Error(`task wall-clock timeout after ${taskTimeoutMs}ms`)), taskTimeoutMs)
|
|
128
|
+
: null;
|
|
129
|
+
const startedAt = Date.now();
|
|
107
130
|
let active = true;
|
|
108
131
|
let step = 0;
|
|
109
132
|
let status = 'completed';
|
|
110
133
|
let errorMessage;
|
|
111
134
|
let lastContent = null;
|
|
135
|
+
let lastToolSignature = null;
|
|
136
|
+
let consecutiveRepeats = 0;
|
|
112
137
|
const totalUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
113
138
|
let sawUsage = false;
|
|
114
139
|
this.emitEvent({ event: 'run_start', model: this.model, task: userInput });
|
|
@@ -120,6 +145,13 @@ RULES OF ENGAGEMENT:
|
|
|
120
145
|
}
|
|
121
146
|
break;
|
|
122
147
|
}
|
|
148
|
+
if (Date.now() > deadline) {
|
|
149
|
+
status = 'timeout';
|
|
150
|
+
if (!this.jsonMode) {
|
|
151
|
+
console.log(chalk.yellow(`\n[TaskTimeout] Wall-clock limit of ${taskTimeoutMs}ms reached; stopping.`));
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
123
155
|
this.trimOldToolResults();
|
|
124
156
|
step++;
|
|
125
157
|
const spinner = this.jsonMode
|
|
@@ -133,13 +165,13 @@ RULES OF ENGAGEMENT:
|
|
|
133
165
|
stream = await withRetry(async () => this.client.chat.completions.create({
|
|
134
166
|
model: this.model,
|
|
135
167
|
messages: this.messages,
|
|
136
|
-
tools: getToolDefinitions(),
|
|
168
|
+
tools: getToolDefinitions(this.config),
|
|
137
169
|
tool_choice: "auto",
|
|
138
170
|
stream: true,
|
|
139
171
|
// Not every OpenAI-compatible provider accepts stream_options;
|
|
140
172
|
// usage tracking is therefore opt-in only.
|
|
141
173
|
...(this.config?.includeUsage ? { stream_options: { include_usage: true } } : {})
|
|
142
|
-
}), {
|
|
174
|
+
}, { signal: abortController.signal }), {
|
|
143
175
|
onRetry: (err, nextAttempt, delayMs) => {
|
|
144
176
|
spinner.text = `API error (${err.message}); retrying in ${Math.round(delayMs / 1000)}s (attempt ${nextAttempt})...`;
|
|
145
177
|
}
|
|
@@ -149,8 +181,14 @@ RULES OF ENGAGEMENT:
|
|
|
149
181
|
spinner.fail('Error during processing');
|
|
150
182
|
if (!this.jsonMode)
|
|
151
183
|
console.error(chalk.red(error.message));
|
|
152
|
-
|
|
153
|
-
|
|
184
|
+
if (abortController.signal.aborted) {
|
|
185
|
+
status = 'timeout';
|
|
186
|
+
errorMessage = `task wall-clock timeout after ${taskTimeoutMs}ms`;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
status = 'error';
|
|
190
|
+
errorMessage = error.message;
|
|
191
|
+
}
|
|
154
192
|
active = false;
|
|
155
193
|
break;
|
|
156
194
|
}
|
|
@@ -231,8 +269,14 @@ RULES OF ENGAGEMENT:
|
|
|
231
269
|
spinner.fail('Error during processing');
|
|
232
270
|
if (!this.jsonMode)
|
|
233
271
|
console.error(chalk.red(error.message));
|
|
234
|
-
|
|
235
|
-
|
|
272
|
+
if (abortController.signal.aborted) {
|
|
273
|
+
status = 'timeout';
|
|
274
|
+
errorMessage = `task wall-clock timeout after ${taskTimeoutMs}ms`;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
status = 'error';
|
|
278
|
+
errorMessage = error.message;
|
|
279
|
+
}
|
|
236
280
|
active = false;
|
|
237
281
|
break;
|
|
238
282
|
}
|
|
@@ -308,6 +352,21 @@ RULES OF ENGAGEMENT:
|
|
|
308
352
|
const truncation = truncateOutput(toolResult);
|
|
309
353
|
const boundedResult = truncation.content;
|
|
310
354
|
const resultLines = boundedResult.split('\n');
|
|
355
|
+
// Loop hygiene (idea from dsh repeat-tool-reminder): identical
|
|
356
|
+
// consecutive calls get an escalating reminder so the model changes
|
|
357
|
+
// approach early instead of burning turns until the step cap.
|
|
358
|
+
const signature = `${functionName}:${canonicalArgs(functionArgs)}`;
|
|
359
|
+
if (signature === lastToolSignature)
|
|
360
|
+
consecutiveRepeats++;
|
|
361
|
+
else {
|
|
362
|
+
consecutiveRepeats = 1;
|
|
363
|
+
lastToolSignature = signature;
|
|
364
|
+
}
|
|
365
|
+
const repeatSuffix = consecutiveRepeats < 3
|
|
366
|
+
? ''
|
|
367
|
+
: consecutiveRepeats < 5
|
|
368
|
+
? `\n[AutoClaw] Note: this is the ${consecutiveRepeats}th identical ${functionName} call in a row. If it keeps returning the same result, change approach instead of repeating it.`
|
|
369
|
+
: `\n[AutoClaw] You have now made the identical ${functionName} call ${consecutiveRepeats} times in a row (arguments: ${JSON.stringify(functionArgs).slice(0, 120)}). This exact call keeps returning the same result. Stop retrying it: change approach, fix the underlying problem, or finish the task with what you already have.`;
|
|
311
370
|
let outputFile = null;
|
|
312
371
|
if (resultLines.length > MAX_PREVIEW_LINES || truncation.truncated) {
|
|
313
372
|
outputFile = await this.saveOutput(functionName, toolResult);
|
|
@@ -340,7 +399,7 @@ RULES OF ENGAGEMENT:
|
|
|
340
399
|
this.messages.push({
|
|
341
400
|
role: "tool",
|
|
342
401
|
tool_call_id: toolCall.id,
|
|
343
|
-
content: boundedResult
|
|
402
|
+
content: boundedResult + repeatSuffix
|
|
344
403
|
});
|
|
345
404
|
}
|
|
346
405
|
}
|
|
@@ -351,6 +410,8 @@ RULES OF ENGAGEMENT:
|
|
|
351
410
|
this.emitEvent({ event: 'usage', step, ...totalUsage });
|
|
352
411
|
}
|
|
353
412
|
}
|
|
413
|
+
if (abortTimer)
|
|
414
|
+
clearTimeout(abortTimer);
|
|
354
415
|
const result = {
|
|
355
416
|
status,
|
|
356
417
|
steps: step,
|
|
@@ -358,9 +419,33 @@ RULES OF ENGAGEMENT:
|
|
|
358
419
|
...(errorMessage ? { error: errorMessage } : {}),
|
|
359
420
|
...(sawUsage ? { usage: totalUsage } : {})
|
|
360
421
|
};
|
|
422
|
+
this.appendRunLog(userInput, result, startedAt);
|
|
361
423
|
this.emitEvent({ event: 'run_end', ...result });
|
|
362
424
|
return result;
|
|
363
425
|
}
|
|
426
|
+
// Best-effort local run history: ~/.autoclaw/logs/runs.jsonl, one line
|
|
427
|
+
// per run, for post-hoc debugging of unattended batches. Logging must
|
|
428
|
+
// never fail a run.
|
|
429
|
+
appendRunLog(userInput, result, startedAt) {
|
|
430
|
+
try {
|
|
431
|
+
const dir = path.join(os.homedir(), '.autoclaw', 'logs');
|
|
432
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
433
|
+
const line = JSON.stringify({
|
|
434
|
+
time: new Date().toISOString(),
|
|
435
|
+
model: this.model,
|
|
436
|
+
task: String(userInput).slice(0, 200),
|
|
437
|
+
status: result.status,
|
|
438
|
+
steps: result.steps,
|
|
439
|
+
...(result.error ? { error: result.error.slice(0, 300) } : {}),
|
|
440
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
441
|
+
durationMs: Date.now() - startedAt
|
|
442
|
+
});
|
|
443
|
+
fs.appendFileSync(path.join(dir, 'runs.jsonl'), line + '\n');
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// ignore
|
|
447
|
+
}
|
|
448
|
+
}
|
|
364
449
|
// Every turn resends the full history, so early large tool results
|
|
365
450
|
// dominate context growth. Keep the most recent results intact and bound
|
|
366
451
|
// older ones to a short excerpt (full output stays on disk via /view when
|
package/dist/batch.js
CHANGED
|
@@ -25,6 +25,8 @@ export function parseManifest(raw) {
|
|
|
25
25
|
const entry = { lineNo, id, task: parsed.task.trim() };
|
|
26
26
|
if (typeof parsed.maxSteps === 'number' && parsed.maxSteps > 0)
|
|
27
27
|
entry.maxSteps = parsed.maxSteps;
|
|
28
|
+
if (typeof parsed.taskTimeoutMs === 'number' && parsed.taskTimeoutMs > 0)
|
|
29
|
+
entry.taskTimeoutMs = parsed.taskTimeoutMs;
|
|
28
30
|
if (typeof parsed.model === 'string' && parsed.model.trim())
|
|
29
31
|
entry.model = parsed.model.trim();
|
|
30
32
|
if (typeof parsed.provider === 'string' && parsed.provider.trim())
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
|
|
|
46
46
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
47
|
// In dist/index.js, package.json is usually up one level in the root
|
|
48
48
|
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
49
|
-
let version = '1.3.
|
|
49
|
+
let version = '1.3.5';
|
|
50
50
|
try {
|
|
51
51
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
52
52
|
version = pkg.version;
|
|
@@ -615,7 +615,8 @@ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
|
|
|
615
615
|
...fullConfig,
|
|
616
616
|
// The results file is the machine-readable contract in batch mode.
|
|
617
617
|
jsonMode: false,
|
|
618
|
-
maxSteps: entry.maxSteps ?? fullConfig.maxSteps
|
|
618
|
+
maxSteps: entry.maxSteps ?? fullConfig.maxSteps,
|
|
619
|
+
taskTimeoutMs: entry.taskTimeoutMs ?? fullConfig.taskTimeoutMs
|
|
619
620
|
};
|
|
620
621
|
const agent = new Agent(apiKey, taskBaseURL, taskModel, taskConfig);
|
|
621
622
|
const start = Date.now();
|
|
@@ -667,9 +668,9 @@ async function runChat(queryParts, options) {
|
|
|
667
668
|
}
|
|
668
669
|
const result = await agent.chat(initialQuery);
|
|
669
670
|
// Headless mode exit — the exit code is the orchestrator-facing outcome:
|
|
670
|
-
// 0 completed, 1 hard failure, 2 step cap
|
|
671
|
+
// 0 completed, 1 hard failure, 2 unfinished (step cap or wall-clock timeout).
|
|
671
672
|
if (!options.interactive) {
|
|
672
|
-
process.exit(result.status === 'completed' ? 0 : result.status === 'max_steps' ? 2 : 1);
|
|
673
|
+
process.exit(result.status === 'completed' ? 0 : result.status === 'max_steps' || result.status === 'timeout' ? 2 : 1);
|
|
673
674
|
}
|
|
674
675
|
}
|
|
675
676
|
// Main chat loop
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { shellInvocation } from './shell.js';
|
|
4
|
+
export function resolveSandboxMode(config) {
|
|
5
|
+
const requested = String(config?.sandbox || process.env.AUTOCLOW_SANDBOX || 'danger-full-access').toLowerCase();
|
|
6
|
+
if (requested === 'read-only' || requested === 'workspace-write' || requested === 'danger-full-access') {
|
|
7
|
+
return requested;
|
|
8
|
+
}
|
|
9
|
+
return 'danger-full-access';
|
|
10
|
+
}
|
|
11
|
+
export function sandboxBackend(platform = process.platform) {
|
|
12
|
+
if (platform === 'linux') {
|
|
13
|
+
const available = hasExecutable('bwrap');
|
|
14
|
+
return {
|
|
15
|
+
backend: 'bwrap',
|
|
16
|
+
available,
|
|
17
|
+
detail: available
|
|
18
|
+
? 'bubblewrap found'
|
|
19
|
+
: 'bubblewrap (bwrap) not found — install it (e.g. apt install bubblewrap) or set sandbox to danger-full-access'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (platform === 'darwin') {
|
|
23
|
+
const available = fs.existsSync('/usr/bin/sandbox-exec');
|
|
24
|
+
return {
|
|
25
|
+
backend: 'sandbox-exec',
|
|
26
|
+
available,
|
|
27
|
+
detail: available ? 'sandbox-exec found' : 'sandbox-exec missing (unexpected on macOS)'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
backend: 'none',
|
|
32
|
+
available: false,
|
|
33
|
+
detail: 'no sandbox backend on this platform yet (Windows restricted-token rung is planned). Set sandbox to danger-full-access to run unrestricted.'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function hasExecutable(name) {
|
|
37
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.existsSync(path.join(dir, name)))
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// skip unreadable PATH entries
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// Pure shape builder: wraps the platform shell invocation so the command can
|
|
49
|
+
// only write where the mode allows. danger-full-access returns the command
|
|
50
|
+
// unchanged; read-only/workspace-write require the platform backend, which
|
|
51
|
+
// the caller checks with sandboxBackend() first (fail-closed otherwise).
|
|
52
|
+
export function sandboxedInvocation(command, mode, workspace, platform = process.platform) {
|
|
53
|
+
const base = shellInvocation(command);
|
|
54
|
+
if (mode === 'danger-full-access')
|
|
55
|
+
return base;
|
|
56
|
+
if (platform === 'linux') {
|
|
57
|
+
if (mode === 'read-only') {
|
|
58
|
+
return {
|
|
59
|
+
file: 'bwrap',
|
|
60
|
+
args: ['--ro-bind', '/', '/', '--dev-bind', '/dev', '/dev', '--proc', '/proc', '--', base.file, ...base.args]
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
file: 'bwrap',
|
|
65
|
+
args: [
|
|
66
|
+
'--ro-bind', '/', '/',
|
|
67
|
+
'--bind', workspace, workspace,
|
|
68
|
+
'--dev-bind', '/dev', '/dev',
|
|
69
|
+
'--proc', '/proc',
|
|
70
|
+
'--tmpfs', '/tmp',
|
|
71
|
+
'--', base.file, ...base.args
|
|
72
|
+
]
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (platform === 'darwin') {
|
|
76
|
+
const ws = workspace.replace(/"/g, '\\"');
|
|
77
|
+
if (mode === 'read-only') {
|
|
78
|
+
return {
|
|
79
|
+
file: '/usr/bin/sandbox-exec',
|
|
80
|
+
args: ['-p', '(version 1)(allow default)(deny file-write*)', base.file, ...base.args]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
file: '/usr/bin/sandbox-exec',
|
|
85
|
+
args: [
|
|
86
|
+
'-p',
|
|
87
|
+
`(version 1)(allow default)(deny file-write*)(allow file-write* (subpath "${ws}") (subpath "/private/tmp") (regex #"^/private/var/folders/"))`,
|
|
88
|
+
base.file,
|
|
89
|
+
...base.args
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { blocked: true, detail: sandboxBackend(platform).detail };
|
|
94
|
+
}
|
package/dist/shell.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, execSync } from 'child_process';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
3
4
|
const GIT_BASH_CANDIDATES = [
|
|
4
5
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
5
6
|
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
@@ -7,6 +8,7 @@ const GIT_BASH_CANDIDATES = [
|
|
|
7
8
|
];
|
|
8
9
|
let cachedType = null;
|
|
9
10
|
let cachedBashPath;
|
|
11
|
+
let cachedBashEnv = null;
|
|
10
12
|
function findBashPath() {
|
|
11
13
|
if (process.platform === 'win32') {
|
|
12
14
|
for (const candidate of GIT_BASH_CANDIDATES) {
|
|
@@ -31,6 +33,26 @@ export function getBashPath() {
|
|
|
31
33
|
cachedBashPath = findBashPath();
|
|
32
34
|
return cachedBashPath;
|
|
33
35
|
}
|
|
36
|
+
// A login shell (-l) would source the profile to put Git's /usr/bin on
|
|
37
|
+
// PATH, but that costs ~450ms per command. Prepending the directories to
|
|
38
|
+
// the child PATH achieves the same tool resolution at spawn speed.
|
|
39
|
+
export function spawnEnv() {
|
|
40
|
+
if (process.platform !== 'win32')
|
|
41
|
+
return process.env;
|
|
42
|
+
if (cachedBashEnv)
|
|
43
|
+
return cachedBashEnv;
|
|
44
|
+
const env = { ...process.env };
|
|
45
|
+
const bp = getBashPath();
|
|
46
|
+
if (bp) {
|
|
47
|
+
const usrBin = path.dirname(bp);
|
|
48
|
+
const gitRoot = path.resolve(usrBin, '..', '..');
|
|
49
|
+
const extra = [usrBin, path.join(gitRoot, 'mingw64', 'bin'), path.join(gitRoot, 'cmd')]
|
|
50
|
+
.filter(p => fs.existsSync(p));
|
|
51
|
+
env.PATH = `${extra.join(path.delimiter)}${path.delimiter}${env.PATH ?? ''}`;
|
|
52
|
+
}
|
|
53
|
+
cachedBashEnv = env;
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
34
56
|
export function resolveShellType(config) {
|
|
35
57
|
const requested = String(config?.shell || process.env.AUTOCLOW_SHELL || 'auto').toLowerCase();
|
|
36
58
|
if (requested === 'bash' || requested === 'powershell' || requested === 'cmd' || requested === 'sh') {
|
|
@@ -57,28 +79,42 @@ export function smartDecode(buf) {
|
|
|
57
79
|
}
|
|
58
80
|
}
|
|
59
81
|
}
|
|
60
|
-
export function
|
|
82
|
+
export function shellInvocation(command) {
|
|
61
83
|
const type = resolveShellType();
|
|
62
|
-
let file;
|
|
63
|
-
let args;
|
|
64
84
|
if (type === 'bash') {
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
file
|
|
68
|
-
args = ['-l', '-c', command];
|
|
85
|
+
// --noprofile/--norc: profile loading costs ~450ms per command; PATH is
|
|
86
|
+
// handled by spawnEnv() instead.
|
|
87
|
+
return { file: getBashPath(), args: ['--noprofile', '--norc', '-c', command] };
|
|
69
88
|
}
|
|
70
|
-
|
|
71
|
-
file
|
|
72
|
-
args = ['-NoProfile', '-Command', command];
|
|
89
|
+
if (type === 'powershell') {
|
|
90
|
+
return { file: 'powershell.exe', args: ['-NoProfile', '-Command', command] };
|
|
73
91
|
}
|
|
74
|
-
|
|
75
|
-
file
|
|
76
|
-
args = ['/d', '/s', '/c', command];
|
|
92
|
+
if (type === 'cmd') {
|
|
93
|
+
return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', command] };
|
|
77
94
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
return { file: '/bin/sh', args: ['-c', command] };
|
|
96
|
+
}
|
|
97
|
+
export function killProcessTree(pid) {
|
|
98
|
+
if (process.platform !== 'win32') {
|
|
99
|
+
// The negative pid targets the detached process group.
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pid, 'SIGKILL');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 'SIGKILL');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// already gone
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
81
112
|
}
|
|
113
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
114
|
+
}
|
|
115
|
+
export function execShellCommand(command, opts) {
|
|
116
|
+
const type = resolveShellType();
|
|
117
|
+
const { file, args } = opts.sandboxed ?? shellInvocation(command);
|
|
82
118
|
return new Promise(resolve => {
|
|
83
119
|
const stdout = [];
|
|
84
120
|
const stderr = [];
|
|
@@ -87,7 +123,7 @@ export function execShellCommand(command, opts) {
|
|
|
87
123
|
let truncated = false;
|
|
88
124
|
let settled = false;
|
|
89
125
|
const posix = process.platform !== 'win32';
|
|
90
|
-
const child = spawn(file, args, { windowsHide: true, detached: posix });
|
|
126
|
+
const child = spawn(file, args, { windowsHide: true, detached: posix, env: spawnEnv() });
|
|
91
127
|
const finish = () => {
|
|
92
128
|
if (settled)
|
|
93
129
|
return;
|
|
@@ -101,21 +137,8 @@ export function execShellCommand(command, opts) {
|
|
|
101
137
|
});
|
|
102
138
|
};
|
|
103
139
|
const killTree = () => {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
if (posix) {
|
|
107
|
-
try {
|
|
108
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
child.kill('SIGKILL');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
// Terminating the shell alone would orphan grandchildren, whose
|
|
116
|
-
// inherited stdio pipes keep 'close' pending — kill the whole tree.
|
|
117
|
-
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
118
|
-
}
|
|
140
|
+
if (child.pid)
|
|
141
|
+
killProcessTree(child.pid);
|
|
119
142
|
};
|
|
120
143
|
const timer = setTimeout(() => {
|
|
121
144
|
timedOut = true;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { killProcessTree, shellInvocation, spawnEnv } from '../shell.js';
|
|
6
|
+
import { resolveSandboxMode, sandboxBackend, sandboxedInvocation } from '../sandbox.js';
|
|
7
|
+
import { matchDangerousPattern } from './core.js';
|
|
8
|
+
const handles = new Map();
|
|
9
|
+
const MAX_TRACKED = 20;
|
|
10
|
+
function newHandleId() {
|
|
11
|
+
return `bg-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
12
|
+
}
|
|
13
|
+
function tailOf(logPath, bytes = 4000) {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(logPath))
|
|
16
|
+
return '(no output yet)';
|
|
17
|
+
const stat = fs.statSync(logPath);
|
|
18
|
+
const start = Math.max(0, stat.size - bytes);
|
|
19
|
+
const fd = fs.openSync(logPath, 'r');
|
|
20
|
+
try {
|
|
21
|
+
const buf = Buffer.alloc(stat.size - start);
|
|
22
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
23
|
+
return (start > 0 ? '…(earlier output omitted)\n' : '') + buf.toString('utf-8');
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
fs.closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return `(could not read log: ${err.message})`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const StartBackgroundProcessTool = {
|
|
34
|
+
name: "Background Process Starter",
|
|
35
|
+
definition: {
|
|
36
|
+
type: "function",
|
|
37
|
+
function: {
|
|
38
|
+
name: "start_background_process",
|
|
39
|
+
description: "Start a long-lived command in the background without blocking: dev servers, watchers, long builds. Output is written to a log file you can read later.",
|
|
40
|
+
parameters: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
command: { type: "string", description: "The command to run in the background." },
|
|
44
|
+
rationale: { type: "string", description: "Explain why this command should run in the background." }
|
|
45
|
+
},
|
|
46
|
+
required: ["command", "rationale"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
handler: async (args, config) => {
|
|
51
|
+
const command = String(args.command ?? '').trim();
|
|
52
|
+
if (!command)
|
|
53
|
+
return 'Error: "command" is required.';
|
|
54
|
+
// Same safety gate as the foreground shell tool.
|
|
55
|
+
const gateLabel = matchDangerousPattern(command);
|
|
56
|
+
if (gateLabel) {
|
|
57
|
+
return `Error: command blocked by AutoClaw safety policy (matched: ${gateLabel}). It was NOT started. If this task genuinely requires it, restart AutoClaw with --allow-dangerous.`;
|
|
58
|
+
}
|
|
59
|
+
// Same sandbox policy as the foreground shell tool.
|
|
60
|
+
const sandboxMode = resolveSandboxMode(config);
|
|
61
|
+
let target = shellInvocation(command);
|
|
62
|
+
if (sandboxMode !== 'danger-full-access') {
|
|
63
|
+
const backend = sandboxBackend();
|
|
64
|
+
if (!backend.available) {
|
|
65
|
+
return `Error: sandbox mode "${sandboxMode}" is configured but no sandbox backend is available: ${backend.detail}`;
|
|
66
|
+
}
|
|
67
|
+
const inv = sandboxedInvocation(command, sandboxMode, process.cwd());
|
|
68
|
+
if ('blocked' in inv) {
|
|
69
|
+
return `Error: sandbox mode "${sandboxMode}" cannot wrap this command: ${inv.detail}`;
|
|
70
|
+
}
|
|
71
|
+
target = inv;
|
|
72
|
+
}
|
|
73
|
+
for (const [id, h] of handles) {
|
|
74
|
+
if (h.exited && Date.now() - h.startedAt > 3600_000)
|
|
75
|
+
handles.delete(id);
|
|
76
|
+
}
|
|
77
|
+
if (handles.size >= MAX_TRACKED) {
|
|
78
|
+
return `Error: too many tracked background processes (${MAX_TRACKED}). Stop finished ones first or wait for them to exit.`;
|
|
79
|
+
}
|
|
80
|
+
const id = newHandleId();
|
|
81
|
+
const logDir = path.join(os.homedir(), '.autoclaw', 'output');
|
|
82
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
83
|
+
const logPath = path.join(logDir, `bg-${id}.log`);
|
|
84
|
+
const { file: logFile, args: spawnArgs } = target;
|
|
85
|
+
const out = fs.openSync(logPath, 'a');
|
|
86
|
+
let child;
|
|
87
|
+
try {
|
|
88
|
+
child = spawn(logFile, spawnArgs, {
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
detached: process.platform !== 'win32',
|
|
91
|
+
env: spawnEnv(),
|
|
92
|
+
stdio: ['ignore', out, out]
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
fs.closeSync(out);
|
|
97
|
+
return `Error: failed to start background process: ${err.message}`;
|
|
98
|
+
}
|
|
99
|
+
fs.closeSync(out);
|
|
100
|
+
const entry = { id, pid: child.pid, command, logPath, startedAt: Date.now(), exited: false, exitCode: null };
|
|
101
|
+
handles.set(id, entry);
|
|
102
|
+
child.on('exit', code => { entry.exited = true; entry.exitCode = code ?? null; });
|
|
103
|
+
child.unref();
|
|
104
|
+
return `Background process started.\n- handle id: ${id}\n- pid: ${child.pid}\n- log: ${logPath}\nUse check_background_process with this id to read output, or stop_background_process to terminate it.`;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export const CheckBackgroundProcessTool = {
|
|
108
|
+
name: "Background Process Checker",
|
|
109
|
+
definition: {
|
|
110
|
+
type: "function",
|
|
111
|
+
function: {
|
|
112
|
+
name: "check_background_process",
|
|
113
|
+
description: "Check a background process started with start_background_process: running/exited status and the tail of its output log.",
|
|
114
|
+
parameters: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
id: { type: "string", description: "The handle id returned by start_background_process." }
|
|
118
|
+
},
|
|
119
|
+
required: ["id"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
handler: async (args) => {
|
|
124
|
+
const id = String(args.id ?? '');
|
|
125
|
+
const entry = handles.get(id);
|
|
126
|
+
if (!entry) {
|
|
127
|
+
return `Error: unknown background process id "${id}". Handles live for the current AutoClaw process only; the log file may still exist under ~/.autoclaw/output/.`;
|
|
128
|
+
}
|
|
129
|
+
const status = entry.exited
|
|
130
|
+
? `exited (code: ${entry.exitCode ?? 'unknown'})`
|
|
131
|
+
: `running (pid: ${entry.pid}, ${Math.round((Date.now() - entry.startedAt) / 1000)}s elapsed)`;
|
|
132
|
+
return `Background process ${id}: ${status}\nCommand: ${entry.command}\nLog tail:\n${tailOf(entry.logPath)}`;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
export const StopBackgroundProcessTool = {
|
|
136
|
+
name: "Background Process Stopper",
|
|
137
|
+
definition: {
|
|
138
|
+
type: "function",
|
|
139
|
+
function: {
|
|
140
|
+
name: "stop_background_process",
|
|
141
|
+
description: "Terminate a background process started with start_background_process (kills the whole process tree).",
|
|
142
|
+
parameters: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
id: { type: "string", description: "The handle id returned by start_background_process." }
|
|
146
|
+
},
|
|
147
|
+
required: ["id"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
handler: async (args) => {
|
|
152
|
+
const id = String(args.id ?? '');
|
|
153
|
+
const entry = handles.get(id);
|
|
154
|
+
if (!entry) {
|
|
155
|
+
return `Error: unknown background process id "${id}".`;
|
|
156
|
+
}
|
|
157
|
+
if (entry.exited) {
|
|
158
|
+
return `Background process ${id} already exited (code: ${entry.exitCode ?? 'unknown'}). Log: ${entry.logPath}`;
|
|
159
|
+
}
|
|
160
|
+
if (entry.pid)
|
|
161
|
+
killProcessTree(entry.pid);
|
|
162
|
+
entry.exited = true;
|
|
163
|
+
return `Background process ${id} terminated.\nLog: ${entry.logPath}\nLog tail:\n${tailOf(entry.logPath)}`;
|
|
164
|
+
}
|
|
165
|
+
};
|