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.zh-CN.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![NPM Downloads](https://img.shields.io/npm/dm/autoclaw.svg?style=flat-square)](https://www.npmjs.com/package/autoclaw)
5
5
  [![GitHub](https://img.shields.io/badge/GitHub-Repository-blue?logo=github&style=flat-square)](https://github.com/tsingliuwin/autoclaw)
6
6
  [![License](https://img.shields.io/npm/l/autoclaw.svg?style=flat-square)](https://github.com/tsingliuwin/autoclaw/blob/main/LICENSE)
7
+ [![Safety](https://img.shields.io/badge/Safety-Notice-yellow?style=flat-square)](./SAFETY.md)
7
8
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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
- - 🛡️ **防失控保护**: 单任务步数上限、API 指数退避重试、Shell 命令超时、工具输出截断,保证模型上下文不被撑爆。
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
- status = 'error';
153
- errorMessage = error.message;
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
- status = 'error';
235
- errorMessage = error.message;
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.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 reached (task unfinished).
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
@@ -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 execShellCommand(command, opts) {
82
+ export function shellInvocation(command) {
61
83
  const type = resolveShellType();
62
- let file;
63
- let args;
64
84
  if (type === 'bash') {
65
- // -l sources the profile so Git Bash's /usr/bin lands on PATH and Unix
66
- // tools (ls, grep, ...) actually resolve.
67
- file = getBashPath();
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
- else if (type === 'powershell') {
71
- file = 'powershell.exe';
72
- args = ['-NoProfile', '-Command', command];
89
+ if (type === 'powershell') {
90
+ return { file: 'powershell.exe', args: ['-NoProfile', '-Command', command] };
73
91
  }
74
- else if (type === 'cmd') {
75
- file = process.env.ComSpec || 'cmd.exe';
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
- else {
79
- file = '/bin/sh';
80
- args = ['-c', command];
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 (!child.pid)
105
- return;
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
+ };