Skip to content

Commit 265e01c

Browse files
author
cw
committed
feat: add complex daily tasks (bash -c, osascript) with 6 bug fixes
End-to-end tested across WebSocket, iOS Simulator, and Android Emulator. Changes: - RunCommandExecutor: safety blocklist, 120s timeout, working dir support - IntentRecognizer: 30+ complex quick paths (scripts, macOS automation) - Ollama prompts: bash -c and osascript -e format examples - ResultWidget: smart terminal/script/AppleScript labels, timeout/blocked cards - ChatPage: context-aware processing messages, categorized welcome Bug fixes: - #1 Device pairing blocking simple auth fallback - #2 Missing auth_required handler in Flutter - #3 Token algorithm mismatch (SHA256 vs simple hash) - #4 PermissionManager denying all tasks - #5 Capability args type corruption (List→String via toString) - #6 Capability executor 30s timeout too short Test results: 25/26 passed (1 expected block), 0 failures
1 parent 0c5a0c6 commit 265e01c

27 files changed

+2093
-607
lines changed

daemon/lib/capabilities/capability_executor.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,20 @@ class ExecutionContext {
9090
Map<String, dynamic> resolveParams(Map<String, dynamic> params) {
9191
final resolved = <String, dynamic>{};
9292

93+
// Pattern for a single variable reference like ${args}
94+
final singleVarPattern = RegExp(r'^\$\{(\w+)\}$');
95+
9396
params.forEach((key, value) {
9497
if (value is String) {
95-
resolved[key] = resolveTemplate(value);
98+
// If the entire value is a single ${var} reference,
99+
// return the original value preserving its type (e.g., List)
100+
final match = singleVarPattern.firstMatch(value);
101+
if (match != null) {
102+
final varName = match.group(1)!;
103+
resolved[key] = variables[varName] ?? '';
104+
} else {
105+
resolved[key] = resolveTemplate(value);
106+
}
96107
} else if (value is Map<String, dynamic>) {
97108
resolved[key] = resolveParams(value);
98109
} else if (value is List) {
@@ -137,7 +148,7 @@ class CapabilityExecutor {
137148

138149
CapabilityExecutor({
139150
required CapabilityRegistry registry,
140-
this.defaultTimeout = const Duration(seconds: 30),
151+
this.defaultTimeout = const Duration(seconds: 120),
141152
}) : _registry = registry;
142153

143154
/// Register an action handler

daemon/lib/capabilities/capability_registry.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ class CapabilityRegistry {
257257
'command': r'${command}',
258258
'args': r'${args}',
259259
},
260+
timeout: const Duration(seconds: 120),
260261
),
261262
],
262263
requiresExecutors: ['run_command'],

daemon/lib/core/daemon.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class Daemon {
111111
_mobileManager = MobileConnectionManager(
112112
port: 9876,
113113
authSecret: 'opencli-dev-secret',
114+
useDevicePairing: false,
114115
);
115116
await _mobileManager.start();
116117
TerminalUI.success('Mobile connection server listening on port 9876', prefix: ' ✓');

daemon/lib/mobile/mobile_connection_manager.dart

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22
import 'dart:convert';
33
import 'dart:io';
4+
import 'package:crypto/crypto.dart';
45
import 'package:web_socket_channel/web_socket_channel.dart';
56
import 'package:web_socket_channel/io.dart';
67
import 'package:path/path.dart' as path;
@@ -238,13 +239,8 @@ class MobileConnectionManager {
238239
print('Paired device authenticated: $deviceId');
239240
return deviceId;
240241
} else {
241-
// Device not paired, need to pair first
242-
_sendMessage(channel, {
243-
'type': 'auth_required',
244-
'message': 'Device not paired. Please scan the pairing QR code first.',
245-
'requires_pairing': true,
246-
});
247-
return null;
242+
// Device not paired - fall through to simple auth
243+
print('Device $deviceId not paired, trying simple auth fallback');
248244
}
249245
}
250246

@@ -255,8 +251,10 @@ class MobileConnectionManager {
255251
return null;
256252
}
257253

258-
final expectedToken = _generateSimpleAuthToken(deviceId, timestamp);
259-
if (token != expectedToken) {
254+
// Accept both SHA256 and simple hash tokens for compatibility
255+
final simpleFallbackToken = _generateSimpleAuthToken(deviceId, timestamp);
256+
final sha256Token = _generateSha256AuthToken(deviceId, timestamp);
257+
if (token != simpleFallbackToken && token != sha256Token) {
260258
_sendError(channel, 'Invalid authentication token');
261259
return null;
262260
}
@@ -291,6 +289,14 @@ class MobileConnectionManager {
291289
return hash.toRadixString(16);
292290
}
293291

292+
/// Generate SHA256 authentication token (matches Flutter client)
293+
String _generateSha256AuthToken(String deviceId, int timestamp) {
294+
final input = '$deviceId:$timestamp:$authSecret';
295+
final bytes = utf8.encode(input);
296+
final digest = sha256.convert(bytes);
297+
return digest.toString();
298+
}
299+
294300
/// Handle device pairing request
295301
Future<String?> _handlePairing(
296302
WebSocketChannel channel,

daemon/lib/mobile/mobile_task_handler.dart

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -577,19 +577,88 @@ class SystemInfoExecutor extends TaskExecutor {
577577
}
578578

579579
class RunCommandExecutor extends TaskExecutor {
580+
static const _dangerousPatterns = [
581+
r'rm\s+-rf\s+/', // rm -rf /
582+
r'rm\s+-rf\s+~', // rm -rf ~
583+
r'rm\s+-rf\s+\*', // rm -rf *
584+
r':\(\)\s*\{\s*:\|:\s*&\s*\}', // fork bomb
585+
r'dd\s+if=/dev/', // dd overwrite
586+
r'mkfs\.', // format filesystem
587+
r'>(\/dev\/sda|\/dev\/disk)', // overwrite disk
588+
r'chmod\s+-R\s+777\s+/', // chmod 777 /
589+
r'wget.*\|\s*sh', // pipe remote script to shell
590+
r'curl.*\|\s*sh', // pipe remote script to shell
591+
];
592+
593+
static const _defaultTimeout = Duration(seconds: 120);
594+
580595
@override
581596
Future<Map<String, dynamic>> execute(Map<String, dynamic> taskData) async {
582597
final command = taskData['command'] as String;
583-
final args = (taskData['args'] as List<dynamic>?)?.cast<String>() ?? [];
598+
// Handle args as either List or String (JSON may stringify it)
599+
List<String> args;
600+
final rawArgs = taskData['args'];
601+
if (rawArgs is List) {
602+
args = rawArgs.cast<String>();
603+
} else if (rawArgs is String) {
604+
args = rawArgs.isNotEmpty ? [rawArgs] : [];
605+
} else {
606+
args = [];
607+
}
608+
final workingDir = taskData['working_directory'] as String?;
584609

585-
final result = await Process.run(command, args);
610+
// Build the full command string for safety check
611+
final fullCommand = '$command ${args.join(' ')}';
586612

587-
return {
588-
'success': result.exitCode == 0,
589-
'exit_code': result.exitCode,
590-
'stdout': result.stdout,
591-
'stderr': result.stderr,
592-
};
613+
// Safety check
614+
for (final pattern in _dangerousPatterns) {
615+
if (RegExp(pattern).hasMatch(fullCommand)) {
616+
return {
617+
'success': false,
618+
'command': fullCommand,
619+
'error': 'Command blocked for safety: matches dangerous pattern',
620+
'blocked': true,
621+
};
622+
}
623+
}
624+
625+
// Resolve ~ in working directory
626+
String? resolvedDir;
627+
if (workingDir != null) {
628+
resolvedDir = workingDir.replaceFirst('~', Platform.environment['HOME'] ?? '/tmp');
629+
if (!await Directory(resolvedDir).exists()) {
630+
resolvedDir = null; // Fall back to default
631+
}
632+
}
633+
634+
try {
635+
final result = await Process.run(
636+
command,
637+
args,
638+
workingDirectory: resolvedDir,
639+
).timeout(_defaultTimeout);
640+
641+
return {
642+
'success': result.exitCode == 0,
643+
'command': fullCommand,
644+
'exit_code': result.exitCode,
645+
'stdout': result.stdout,
646+
'stderr': result.stderr,
647+
};
648+
} on TimeoutException {
649+
return {
650+
'success': false,
651+
'command': fullCommand,
652+
'error': 'Command timed out after 120 seconds',
653+
'timed_out': true,
654+
};
655+
} catch (e) {
656+
return {
657+
'success': false,
658+
'command': fullCommand,
659+
'error': 'Command failed: $e',
660+
};
661+
}
593662
}
594663
}
595664

daemon/lib/services/ollama_service.dart

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,45 +27,65 @@ class OllamaService {
2727

2828
/// 识别用户意图
2929
Future<Map<String, dynamic>> recognizeIntent(String userInput) async {
30-
final prompt = '''
31-
你是一个智能助手,负责识别用户的指令意图。
32-
33-
用户输入:$userInput
34-
35-
请分析用户的意图,并返回 JSON 格式的结果:
36-
{
37-
"intent": "意图名称",
38-
"confidence": 0.0-1.0,
39-
"parameters": {参数}
40-
}
41-
42-
可用的意图类型:
43-
- screenshot: 截屏、截图
44-
- open_app: 打开应用(参数:app_name)
45-
示例:"打开 Chrome" → {"intent": "open_app", "parameters": {"app_name": "Chrome"}}
46-
- close_app: 关闭应用(参数:app_name)
47-
- open_url: 打开网址(参数:url)
48-
- web_search: 网络搜索(参数:query)
49-
- system_info: 获取系统信息
50-
- open_file: 打开文件(参数:path)
51-
- run_command: 运行 shell 命令(参数:command, args)
52-
- check_process: 检查进程是否运行(参数:process_name)
53-
示例:"检查 chrome 是否运行" → {"intent": "check_process", "parameters": {"process_name": "chrome"}}
54-
- list_processes: 列出运行中的进程
55-
- file_operation: 文件操作(参数:operation, directory)
56-
示例:"查看桌面的文件" → {"intent": "file_operation", "parameters": {"operation": "list", "directory": "~/Desktop"}}
57-
示例:"搜索文档文件夹的PDF" → {"intent": "file_operation", "parameters": {"operation": "search", "directory": "~/Documents", "pattern": "pdf"}}
58-
- ai_query: AI 问答(参数:query)
59-
60-
重要识别规则:
61-
1. 检查进程/程序是否运行 → check_process(不是 system_info)
62-
2. 列出运行中的程序 → list_processes(不是 file_operation)
63-
3. 查看/列出/浏览文件 → file_operation(不是 list_processes)
64-
4. 搜索文件 → file_operation(operation: search)
65-
5. 需要执行 shell 命令 → run_command
66-
6. 获取系统信息(版本、CPU等)→ system_info
67-
68-
只返回 JSON,不要其他内容。
30+
final prompt = '''You are an intent classifier for a macOS automation assistant. Analyze the user's input and return JSON.
31+
32+
User input: $userInput
33+
34+
Return JSON format:
35+
{"intent": "intent_name", "confidence": 0.0-1.0, "parameters": {params}}
36+
37+
Available intents:
38+
39+
1. **open_url** - Open a website (params: url)
40+
"open twitter" → {"intent": "open_url", "parameters": {"url": "https://twitter.com"}}
41+
"send message on twitter" → {"intent": "open_url", "parameters": {"url": "https://twitter.com"}}
42+
"check my email" → {"intent": "open_url", "parameters": {"url": "https://mail.google.com"}}
43+
44+
2. **open_app** - Open a macOS application (params: app_name)
45+
"open Chrome" → {"intent": "open_app", "parameters": {"app_name": "Google Chrome"}}
46+
"launch Terminal" → {"intent": "open_app", "parameters": {"app_name": "Terminal"}}
47+
48+
3. **close_app** - Close/quit an application (params: app_name)
49+
"close Safari" → {"intent": "close_app", "parameters": {"app_name": "Safari"}}
50+
"kill Chrome" → {"intent": "close_app", "parameters": {"app_name": "Google Chrome"}}
51+
52+
4. **run_command** - Execute a shell command (params: command, args)
53+
Simple commands:
54+
"what's my IP" → {"intent": "run_command", "parameters": {"command": "curl", "args": ["-s", "ifconfig.me"]}}
55+
"check disk space" → {"intent": "run_command", "parameters": {"command": "df", "args": ["-h"]}}
56+
"git status" → {"intent": "run_command", "parameters": {"command": "git", "args": ["status"]}}
57+
58+
Multi-step scripts (use bash -c for chained commands):
59+
"show largest files" → {"intent": "run_command", "parameters": {"command": "bash", "args": ["-c", "du -ah ~ -d 3 2>/dev/null | sort -rh | head -20"]}}
60+
"kill process on port 3000" → {"intent": "run_command", "parameters": {"command": "bash", "args": ["-c", "lsof -t -i:3000 | xargs kill -9"]}}
61+
"compress downloads" → {"intent": "run_command", "parameters": {"command": "bash", "args": ["-c", "cd ~/Downloads && zip -r ~/Desktop/archive.zip ."]}}
62+
"show open ports" → {"intent": "run_command", "parameters": {"command": "bash", "args": ["-c", "lsof -i -P -n | grep LISTEN"]}}
63+
"backup documents" → {"intent": "run_command", "parameters": {"command": "bash", "args": ["-c", "rsync -av ~/Documents/ ~/Desktop/backup/"]}}
64+
65+
macOS automation via AppleScript (use osascript -e):
66+
"create a note about shopping" → {"intent": "run_command", "parameters": {"command": "osascript", "args": ["-e", "tell application \\"Notes\\" to make new note with properties {name:\\"shopping\\"}"]}}
67+
"set volume to 50" → {"intent": "run_command", "parameters": {"command": "osascript", "args": ["-e", "set volume output volume 50"]}}
68+
"empty trash" → {"intent": "run_command", "parameters": {"command": "osascript", "args": ["-e", "tell application \\"Finder\\" to empty the trash"]}}
69+
"toggle dark mode" → {"intent": "run_command", "parameters": {"command": "osascript", "args": ["-e", "tell application \\"System Events\\" to tell appearance preferences to set dark mode to not dark mode"]}}
70+
71+
5. **web_search** - Search the web (params: query)
72+
6. **screenshot** - Take a screenshot (no params)
73+
7. **system_info** - Get system information (no params)
74+
8. **check_process** - Check if a process is running (params: process_name)
75+
9. **list_processes** - List running processes (no params)
76+
10. **file_operation** - Browse/list/search files (params: operation, directory, pattern)
77+
11. **ai_query** - General questions needing AI (params: query)
78+
79+
RULES:
80+
1. Social media actions → open_url with the platform URL
81+
2. Multi-step operations → run_command with command: "bash", args: ["-c", "cmd1 && cmd2"]
82+
3. macOS app automation → run_command with command: "osascript", args: ["-e", "applescript"]
83+
4. args MUST be a JSON array of strings
84+
5. run_command is the UNIVERSAL FALLBACK
85+
6. NEVER return "unknown"
86+
7. confidence >= 0.7
87+
88+
Return ONLY JSON.
6989
''';
7090

7191
try {

0 commit comments

Comments
 (0)